这段时间正好把一个基于 Spring Boot 的员工考勤系统从零到一完整落地了。从最开始梳理考勤业务规则,到表结构设计、打卡和审批流程实现,再到每天凌晨自动跑批的数据汇总,前后踩了不少坑,也沉淀出一些可以直接拿来用的经验。这篇文章就把整个设计思路、技术选型、核心功能实现和遇到的典型问题完整记录下来,给正在做类似 Spring Boot 管理系统、或者想从业务角度理解考勤系统的朋友一些参考。
考勤系统看似简单,无非是上班打卡、记录出勤、月底算工资。但真做起来会发现,光“迟到几分钟算迟到”这种问题就牵扯到班次、宽限时间、外勤定位、补卡申请一系列设计。再加上请假审批、加班时长、异常考勤处理,逻辑并不比一套进销存系统简单。我下面按实际开发顺序来讲,从需求拆解开始,到技术栈搭配,再到核心模块的代码级实现和踩坑实录,尽量把关键决策背后的为什么也一并说清楚。
1. 先想清楚考勤业务,再动手写代码
1.1 别被“打卡”两个字带偏:真正要梳理的是角色和流程
很多新手拿到这种题目,第一反应就是做一张打卡表,记录用户ID、打卡时间就完事。但考勤系统的核心绝不是打卡,而是围绕考勤产生的数据如何在“员工—主管—人事”这三方之间流转。
我的做法是先画三角色模型。员工要能打卡、请假、加班申请、查看自己的出勤汇总;部门主管要能审批下属的请假和加班申请、查看所辖团队的异常考勤;HR或系统管理员负责排班、维护班次、处理补卡申请、查看全公司月度报表。这三类角色的数据权限完全不同,后续所有表结构和接口设计都得围绕它们展开。
有了角色之后还要梳理一条主线流程:员工某天正常上班,在班次时间窗内打卡,系统自动记录并判断状态;如果请假,走申请流程,主管审批通过后,系统在日结时按请假类型处理当天考勤;如果漏打卡,员工发起补卡申请,审批通过后修正当天记录;每天晚上系统自动跑批,生成当天的考勤汇总;月底再汇总成月度报表,供薪资系统使用。这条链路一旦理清,功能清单就自然而然有了,后续写代码只是把这些节点逐个实现。
1.2 功能需求拆成四个模块:打卡、审批、统计、异常管理
我实际开发时把功能拆成四个模块,而不是按角色拆。这样代码分层更干净,业务边界也清晰。
- 打卡模块:上班卡、下班卡、外勤打卡、补卡申请。核心是打卡时间窗判断。
- 审批模块:请假申请与审批、加班申请与审批、补卡审批。核心是状态流转和多级审批。
- 统计模块:日汇总、月汇总、个人出勤明细、部门出勤统计。核心是精确的工时计算和异常归类。
- 异常管理模块:迟到、早退、缺卡、旷工提醒,异常改判。核心是标记规则和人工修正记录。
这四个模块之间不是孤立的。请假审批通过后,要影响当天统计;补卡审批通过后,要回写打卡记录;加班审批通过后,要在月末汇总时生成加班工时。所以我在设计表的时候就预留了审批单号与考勤记录的关联字段,初衷很简单:考勤的最终结果,必须是由“打卡记录 + 审批单据”共同决定的,少了任何一个都不完整。
1.3 状态机设计:审批流程别用一堆if else硬写
审批模块是最容易被低估的部分。请假从提交到归档,中间至少经历“草稿、待审批、已通过、已驳回”四个状态,如果公司要求部门主管审批后再由人事复核,状态就更多。
这里一定要用状态机思维,而不是在每个Service方法里堆if (status == 1) { ... } else if (status == 2) { ... }。我的实现方式是定义一个审批状态枚举,每个枚举里维护“可流转到哪些状态”和“当前状态允许谁操作”。
public enum ApproveStatus { DRAFT(0, "草稿"), PENDING(1, "待审批"), APPROVED(2, "已通过"), REJECTED(3, "已驳回"), CANCELLED(4, "已撤销"); }关键点是建一个attendance_approval表,把审批单号、业务类型(请假、加班、补卡)、当前状态、申请材料、审批意见、审批人、审批时间全部统一存储。这样不管是请假还是加班,都走同一条审批链路,前端也可以共用一套审批组件。我最初偷懒给请假和加班分别建了审批表,后来发现写两套逻辑很痛苦,果断合并成一张表,彻底解决了这个问题。
2. 技术选型与项目结构:Spring Boot只是起点,搭配才是关键
2.1 技术栈组合:为什么选MyBatis-Plus而不是JPA
这个项目我用的组合是:Spring Boot 2.7 + MyBatis-Plus 3.5 + MySQL 8.0 + Redis + MinIO + Vue 3,没有用更花哨的微服务组件。
先说为什么是 Spring Boot 2.7 而不是 3.x。考勤系统是典型的单体应用,2.7 版本生态最成熟,网上资料几乎踩不到坑,MyBatis-Plus 和各种生成工具适配也很稳定。遇到有些教程说 Spring Boot 3.x 必须用 jakarta 命名空间、部分框架还没适配的情况,选型时直接避开,省下大量排查时间。如果确实要用 3.x,记得把javax.servlet换成jakarta.servlet,但还是建议没特别需求就停留在 2.7。
MyBatis-Plus 的理由更直接:单表 CRUD 不用写 SQL,LambdaQueryWrapper一行代码搞定条件查询,分页插件也就一个配置的事。考勤系统一半以上的操作是简单的增删改查,用 MyBatis-Plus 能省掉大量重复的 Mapper XML。但复杂统计类 SQL 我仍然手写,比如月汇总报表的关联查询,用框架拼条件反而可读性差。
Redis 在系统里承担两件事:存登录会话和做接口防重复提交。MinIO 用来存打卡时上传的自拍照片和头像,这是参考了公司实际需求加的,比直接把图片存服务器本地更规范。
2.2 项目分层结构:一张图记住包结构
很多人拿到 Spring Boot 项目不知道包怎么划分。我的做法是严格按controller / service / mapper / entity / dto / vo六层来组织。
com.company.attendance ├── controller # 接收请求,参数校验,返回统一结果 ├── service # 业务逻辑层,事务放这里 │ └── impl ├── mapper # MyBatis-Plus 接口 ├── entity # 数据库实体 ├── dto # 前端传参对象 ├── vo # 返回前端的数据封装 ├── config # 配置类,如 Redis、MinIO、拦截器 ├── common # 工具类、统一返回结果、异常处理 └── job # 定时任务分层的好处是,考勤这种业务后续一定会改需求,比如增加一个“加班转调休”的功能,你只需要改 Service 层,Controller 基本不动,Mapper 可能加一条 SQL,改造成本很低。我这次遇到部门主管要求能批量审批,就是只在 Controller 层加了一个批量审核的接口,复用同一个 Service 方法,十分钟搞定。
2.3 数据库表设计:考勤核心表和关系
数据库是考勤系统的地基,我最终设计了几张核心表,这里只挑最关键的三张讲。
第一是attendance_shift班次表。字段包括班次名称、上班时间、下班时间、宽限分钟数、是否跨天、加班起始时间。注意跨天场景,比如晚班 22:00 到次日 06:00,如果只存时间不存跨天标记,日历判断会出大问题。第二是attendance_record打卡记录表,存储员工某天的上班卡、下班卡、打卡来源、定位地址、打卡照片URL、考勤状态。这张表我加了work_date字段单独存“考勤归属日期”,而不是直接用打卡时间,因为晚班跨天时打卡日期和考勤日期是不同的。第三是attendance_daily_summary日汇总表,每天定时任务把原始打卡记录加工后写入这里,包含迟到分钟数、早退分钟数、缺卡标记、请假类型、工时等。前端展示的日考勤直接查这张表,不查明细表,性能压力小很多。
CREATE TABLE attendance_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, work_date DATE NOT NULL, shift_id BIGINT NOT NULL, clock_in_time DATETIME, clock_out_time DATETIME, clock_in_address VARCHAR(255), clock_out_address VARCHAR(255), clock_in_photo VARCHAR(500), clock_out_photo VARCHAR(500), source_type TINYINT COMMENT '1-考勤机 2-手机定位 3-补卡', leave_type TINYINT DEFAULT 0 COMMENT '0-正常 1-事假 2-病假 3-年假', attendance_status TINYINT, create_time DATETIME );这里有个细节:我在建表时把“考勤状态”和“请假类型”分开了。之前有同事把请假直接写成一种考勤状态,结果月末统计时“请假”和“迟到”没办法同时存在,比如上午请假下午迟到,如果合在一起就丢数据。分开存之后,状态判断就灵活了。
3. 核心功能实现:打卡、审批、统计逐一落地
3.1 打卡接口:时间窗、外勤定位、防重复提交
打卡接口是系统的最前端入口,看起来简单,实现时有三件事必须处理好:时间窗判断、重复提交拦截、外勤定位。
时间窗判断的逻辑,我封装成一个独立的AttendanceCalculateUtil工具类,输入班次信息和打卡时间,输出“正常、迟到、早退”等初始状态。判断的核心逻辑并不复杂,但宽限分钟的处理很关键。
LocalTime shiftStart = shift.getStartTime(); LocalTime clockTime = record.getClockInTime().toLocalTime(); // 宽限分钟:比如上班时间 09:00,宽限 5 分钟,09:05 前不算迟到 boolean late = clockTime.isAfter(shiftStart.plusMinutes(shift.getGraceMinutes()));打卡时系统会要求用户处于公司定位范围内,前端把经纬度传来,后端用 haversine 公式计算距离,超过 500 米就拒绝打卡。有些系统做得很粗,只在文章开头写一句“外勤打卡”,实际完全可以做得更细:区分内勤和外勤两种模式,外勤打卡上传定位和现场照片,存到 MinIO 里,这样月末核对时也有据可查。
防重复提交是我强烈建议加的一个功能。如果有个员工手抖连点两次“打卡”,两次请求都进来,就会生成两条上班卡。我在 AOP 层用 Redis 做了一个简单防重机制:以userId + 日期 + 卡类型作为 key,setnx 成功后执行打卡逻辑,执行完再删除 key。这样同一用户同一卡类型同一自然日只能成功一次。
3.2 工时计算:迟到、早退、加班怎么算才不吵架
工时计算是考勤系统的灵魂,也是最容易让业务方不满意的地方。这里必须把规则从业务那边确认清楚,再落到代码里。
我采用的是一种比较通用的算法:应出勤工时取“打卡实际时长”和“班次计划时长”的逻辑关系,而不是直接硬算两次打卡间隔。比如某天班次 09:00 到 18:00,员工 09:30 才到,18:00 准时走,那实际在岗时间是 8.5 小时,按 8 小时计还是按 8.5 小时计,薪资逻辑完全不同。我这里的规则是:迟到时长按分钟扣减,早退同理,午休时间固定扣除。这样算下来每天工时 = 下班打卡时间 - 上班打卡时间 - 午休分钟数 - 迟到分钟数 - 早退分钟数,再和应出勤工时做比较。
加班工时的计算要关联加班审批单。员工加班前先提交加班申请,审批通过后,系统才把他 18:00 之后的打卡时长记为加班工时。没有审批单的时间,即使人在公司也不算加班。这个逻辑必须在代码里强制,否则后面人事核对时各种扯皮。实现上,我在attendance_daily_summary表里加一个overtime_minutes字段,每天跑批时先查当天有没有审批通过的加班单,再结合下班打卡时间计算加班分钟数。
3.3 请假审批:一张表统一搞定所有审批类型
请假模块我重点解决了“不同请假类型,不同审批流程”的问题。最初的设计是每个请假类型写一个审批方法,后来发现代码重复率极高,无非是判断一下请假天数有没有超过主管权限。我把审批流程整合成统一的ApprovalService.submit(ApprovalDTO)。前端传审批类型、申请人、起止日期、事由,后端自动判断审批层级。
@Transactional(rollbackFor = Exception.class) public void approve(Long approvalId, Long approverId, boolean pass, String comment) { // 校验当前审批人是否有权限操作当前状态 // 追加一条审批记录 // 更新审批单状态 // 如果最终通过,则回调对应的业务处理 }审批通过后的业务回调我是用 Spring 的事件机制实现,发布一个ApprovalPassedEvent,请假、加班、补卡模块各自监听事件执行自己的后置逻辑。这样做的最大好处是,以后加一种新的审批业务类型,不需要改动审批主链路代码,只需要新增监听器就行。
3.4 统计报表与Excel导出:给HR少加一点班
月底给人事用的月度统计报表,核心是“应出勤天数、实际出勤天数、迟到次数、早退次数、请假天数、加班总时长”六项指标。我建了一张attendance_monthly_summary表,每月1号定时跑批生成。跑批逻辑比较直接:当月有排班且没有请假的天数算应出勤;每天汇总表状态为正常的算实际出勤;迟到、早退次数从每日状态里count;请假天数汇总leave_days;加班时长汇总overtime_minutes。
Excel 导出我用的是 EasyExcel,一个注解标注字段名就能生成报表。实际操作中遇到的问题是,月底跑批时如果某天考勤状态是“缺卡”但补卡流程还没走完,这天的数据会被自动标记成异常,等补卡审批通过后日汇总状态变化,月度汇总却不会自动更新。我在跑批逻辑里加了一个“重新计算”机制:每月2号之前如果补卡审批完成,重新拉取当月数据刷新月度汇总。这个细节如果不处理,人事用 Excel 对账时就会发现数字对不上。
4. 实战中一定会踩的坑:排查过程与经验
4.1 定时任务漏跑:Spring Schedule的隐藏陷阱
日结和月结都依赖定时任务,我用的是 Spring Boot 自带的@Scheduled注解。开发阶段一切正常,但我模拟跨天测试时,发现有一天日结任务跑出来后,第二天早上看数据却少了几个人的记录。排查了半天发现问题出在任务执行时间上:我设置的日结时间是0 30 0 * * ?,也就是凌晨 00:30,但有个页面的日期边界判断用的是new Date(),而数据库里的work_date已经过了零点,就对不上了。
这里的教训是:所有考勤相关逻辑的“当前日期”不要直接取系统时间,而是由定时任务框架统一传入业务日期。我在日结任务里手动指定“统计昨天”而不是“统计今天”,代码里写死了LocalDate.now().minusDays(1),跨天测试就再也没出过问题。
4.2 事务失效:同一个类内调用导致@Transactional不生效
有段时间补卡审批通过后,总出现“审批状态更新了,但考勤记录没有同步修正”的现象。查日志发现异常被吞了。后来定位到是事务没有生效。问题出在我把approve()方法和修正打卡记录的方法写在了同一个 Service 类里,而调用时使用了this.approve(),Spring AOP 基于代理实现,内部自调用绕过了代理,事务注解自然就失效了。
解决方法是把修正打卡记录的逻辑拆到独立的AttendanceAdjustService,在ApprovalService里注入它来调用。这里提醒一句:任何涉及多表更新的业务方法,不要写成同类自调用,要么拆类,要么从外部Bean调用,否则事务很容易静默失效。
4.3 时区与时间格式:LocalDateTime不是存的万能药
考勤系统对时间的精确度要求极高,时区问题非常隐蔽。项目初期本地测试一切正常,部署到服务器后发现打卡记录全部差了 8 小时。原因很简单:MySQL 的连接串里没有加serverTimezone=Asia/Shanghai,而且服务器系统时区默认是 UTC,应用与数据库的时区不一致导致 JDBC 在转换时间时做了偏移。
我的处理方法是统一所有时间标准。数据库连接参数明确指定serverTimezone=Asia/Shanghai,应用配置spring.jackson.time-zone=GMT+8,所有实体时间字段统一用LocalDateTime而不是Date。这样就避免了大部分时区转换问题。另外,前端 Vue 页面拿到的时间戳后要用dayjs按东八区格式化,避免浏览器时区差异把时间显示错乱。
4.4 Redis缓存穿透:频繁查询不存在的员工导致数据库压力陡增
做考勤详情页时,我先查 Redis 缓存员工信息,没有再去查数据库。测试发现有人反复访问“不存在的员工ID”,每次都绕过缓存直达数据库,查库次数暴增。虽然考勤系统并发量不算高,但这种明显可以被攻击的接口还是要防。
防穿透的常规方案是缓存空值。查不到数据库时,把空对象也写进 Redis,过期时间设置成 3 分钟,这样短时间内的相同查询直接返回空,不会压到数据库。针对恶意乱传ID的请求,还可以在 Controller 层加参数校验,ID 小于等于 0 直接返回参数错误。
4.5 Spring Boot版本与前端打包:vue项目放进Spring Boot的坑
部署阶段我用的是把构建好的 Vue 静态资源复制到 Spring Boot 的static目录,这样同一个端口就能同时提供前端页面和后端接口。这里有一个需要注意的地方:如果使用 history 模式路由,刷新页面会出现 404。解决办法是配置静态资源路径,把未匹配的请求转发到index.html。
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{spring:\\w+}") .setViewName("forward:/index.html"); } }另外一个版本相关的坑是,Spring Boot 2.7 里如果引入了spring-boot-starter-validation,空注解校验需要额外添加@Validated到 Controller 层,不然@NotBlank完全不生效。这个找错方向的话会浪费不少时间,建议直接看一眼启动日志有没有MethodValidationPostProcessor的提示。
5. 个人实践体会:如果再做一个考勤系统,我会怎么改
做完这个项目,如果让我再从头做一遍,有三件事我会在一开始就做好。
第一是把可配置做到极致。班次、宽限分钟、迟到扣款规则、审批层级,这些最好都做成配置表,不要写死在枚举或者常量里。我做的时候就是这样,每个公司考勤规则都有自己的特殊条款,业务方第一次提需求往往说不全,系统上线了才开始补充细节,配置化能省掉三天两头发版本的麻烦。
第二是前端界面上把异常颜色标注清楚。迟到、早退、缺卡、请假各项用颜色区分,HR 一目了然。这是后期根据使用反馈加的,但确实比堆数字直观得多,也算是这个项目里性价比最高的一个改动。
第三是重视操作日志。考勤数据涉及工资,修改操作必须留痕。谁改了补卡记录,改了哪天的数据,修改前是什么值,修改后是什么值,这些都应该写入审计表。这次做的时候由于时间有限,只做了简单的日志记录,如果正式商用,这一步一定不能省。
实际做下来最大的感受是:考勤系统技术难度不高,真正的复杂度全在业务规则上。表设计的时候多想一步,状态机定义得干净一点,就能避免后面大量返工。如果你也准备写这个选题的毕设或者工作项目,希望这些经验能帮你少踩几个坑。