1. 项目概述:从零搭建一套能用的考勤系统,到底难在哪
先聊点实在的。提起“员工考勤系统”,很多人第一反应是“这不就是个打卡记录吗,有什么好做的”。但真正接过这类需求的人都知道,考勤系统最麻烦的从来不是打卡本身,而是背后那一堆业务规则:迟到怎么算、早退怎么判、请假调休怎么抵扣、加班时长怎么和排班挂钩……稍微多几个班次、多几种假别,逻辑就会迅速膨胀。而如果恰好在用 Spring Boot 做这套东西,还得同时考虑员工管理、部门层级、权限区分、前端接口设计、数据报表展示这些配套模块,工作量和复杂度一下子就上来了。
这篇内容主要面向两类人:一类是正在做课程设计或毕业设计的学生,需要一套能跑通、结构清晰、能写进论文里的 Spring Boot 考勤系统;另一类是刚入行的开发,想看看别人是怎么把考勤这类业务需求拆解成技术方案的。我会按照实际开发顺序,完整梳理从需求分析到数据库设计、从后端接口到前端页面、从部署到避坑的全部过程,把我个人踩过的坑和总结出的经验一并放出来,希望能帮你少走弯路。
这个项目我用的是 Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Vue 2 + Element UI 这套组合。选型理由后文会详细说,这里先给结论:这是一套最主流、最不缺参考资料、也最容易在答辩或汇报时讲清楚的技术栈,对新手极其友好。
2. 需求设计与技术选型:先想清楚再做,比写代码重要得多
2.1 考勤系统的核心需求拆解
很多人拿到题目直接建表写接口,结果写到一半发现业务逻辑对不上,数据库改了七八遍。我建议先花半天时间把需求列表写出来,哪怕只是在纸上列个清单,后面都能省下大把返工时间。
员工考勤系统最基础的需求,无非这么几条:
- 员工信息管理:包括工号、姓名、所属部门、职位、入职时间、状态等基本档案。
- 考勤打卡记录:支持上班打卡、下班打卡,能记录每次打卡的具体时间。
- 考勤规则配置:包括上下班时间、迟到早退的时间阈值、工作日设置、是否启用弹性打卡等。
- 请假与出差管理:员工提交申请,管理员审核,并自动关联到考勤统计中。
- 考勤统计与报表:按日、按月统计出勤天数、迟到次数、早退次数、缺勤记录,可以导出查看。
- 角色权限:普通员工只能看自己的考勤,部门主管能看本部门,管理员能看全公司。
如果再细化一些,比如考勤异常申诉、加班时长计算、排班管理、调休管理,这些都是加分项,可以看时间安排来选择做还是不做。
我的经验是:第一次做这类系统,优先保证基础流程闭环,也就是“员工打卡→生成考勤记录→管理员审核→统计报表”这个主线,先把这条链路走通,再加复杂规则。否则很容易陷入“规则越加越多、代码越改越乱”的泥潭。
2.2 为什么选 Spring Boot + MyBatis-Plus + Vue
先说说后端框架。Spring Boot 生态成熟、自动化配置省心,适合快速搭建业务系统。而且考勤系统这个题目在面试和答辩中很常见,面试官基本默认你懂 Spring Boot,所以它作为主框架是最稳妥的选择。
持久层我没用传统 MyBatis 的 XML 写一堆手写 SQL,而是选了 MyBatis-Plus。MyBatis-Plus 提供了通用的单表 CRUD 接口,像员工表、部门表这种简单增删改查基本不用写 SQL,可以大大缩短开发周期。遇到多表关联查询时再写自定义 SQL,灵活性也足够。如果我没记错,MyBatis-Plus 的 BaseMapper 里已经封装了 insert、deleteById、selectById、selectPage 这些常用方法,直接用就行。
前端选了 Vue 2 + Element UI。坦白讲,Vue 3 是趋势,但如果你是为了快速完成课程设计,Vue 2 的参考资料多,Element UI 组件库文档全,网上现成的后台管理模板也几乎都是基于这套组合。与其花大量时间折腾组合兼容性问题,不如把精力放在业务功能上。当然,如果你已经熟悉 Vue 3 + Element Plus,直接用也没问题,接口层面完全不受影响。
2.3 项目整体架构设计
我采用的前后端分离架构,后端提供 RESTful API,前端通过 Axios 请求接口。项目结构如下:
attendance-system ├── backend │ ├── src/main/java/com/example/attendance │ │ ├── controller # 接口层 │ │ ├── service # 业务逻辑层 │ │ ├── mapper # MyBatis-Plus 数据访问层 │ │ ├── entity # 实体类 │ │ ├── dto # 数据传输对象 │ │ ├── config # 配置类(拦截器、跨域、异常处理) │ │ └── utils # 工具类 │ ├── src/main/resources │ │ ├── mapper # 自定义 SQL XML(需要时用) │ │ └── application.yml # 配置文件 ├── frontend │ ├── src │ │ ├── api # 接口封装 │ │ ├── views # 页面组件 │ │ ├── router # 路由 │ │ ├── store # 状态管理 │ │ └── utils # 工具类 └── sql └── attendance.sql # 数据库初始化脚本这样分层的核心思想是:Controller 只负责参数接收和结果返回,不写业务逻辑;Service 层专注处理考勤规则、统计计算这些核心逻辑;Mapper 层只跟数据库打交道。层与层之间通过接口调用,互相不渗透。这一点在答辩时非常加分,因为考官一眼就能看出你具备基本的工程化思维。
3. 数据库设计:考勤系统的地基,直接决定后续开发的顺畅程度
3.1 表结构设计思路
数据库设计的好坏,决定了你后面写代码时要多写多少冗余逻辑。我的核心思路是:主表尽量精简,关联表按业务拆分。强烈不建议把所有字段堆在一张表里,比如把打卡记录和请假记录混在一张表,统计时会让你生不如死。
我的数据库至少包含以下五张核心表:
员工表(employee)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增或雪花算法生成 |
| emp_no | varchar(20) | 工号,唯一索引 |
| name | varchar(50) | 姓名 |
| department_id | bigint | 部门ID,关联部门表 |
| position | varchar(50) | 职位 |
| phone | varchar(20) | 联系电话 |
| hire_date | date | 入职日期 |
| status | tinyint | 状态:1在职,0离职 |
部门表(department)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| dept_name | varchar(50) | 部门名称 |
| parent_id | bigint | 上级部门ID,用于树形结构 |
打卡记录表(attendance_record)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| employee_id | bigint | 员工ID |
| clock_date | date | 打卡日期 |
| clock_in_time | datetime | 上班打卡时间 |
| clock_out_time | datetime | 下班打卡时间 |
| status | tinyint | 状态:1正常,2迟到,3早退,4异常 |
这张表可以说是整个系统的核心。建议把上下班打卡拆成两个字段而不是两条记录,这样在统计每日考勤状态时效率更高。如果你需要支持一天多次打卡(比如午休前后各打一次),可以再加字段或者拆子表,但基础架构不宜一开始就搞太复杂。
请假表(leave_request)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| employee_id | bigint | 员工ID |
| leave_type | tinyint | 假别:1事假,2病假,3年假,4调休 |
| start_time | datetime | 开始时间 |
| end_time | datetime | 结束时间 |
| reason | varchar(255) | 请假事由 |
| status | tinyint | 审核状态:0待审,1通过,2驳回 |
考勤统计表(attendance_summary)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| employee_id | bigint | 员工ID |
| summary_month | varchar(7) | 统计月份,如2025-06 |
| work_days | int | 应出勤天数 |
| actual_days | int | 实际出勤天数 |
| late_count | int | 迟到次数 |
| early_count | int | 早退次数 |
| absent_days | int | 缺勤天数 |
| leave_days | int | 请假天数 |
统计表最好做物化处理,也就是通过定时任务或手动触发计算后落库,而不是每次查询时实时聚合。原因很简单:当数据量过万之后,实时聚合的速度会越来越慢,考官或领导看到页面转圈超过三秒就会皱眉。定时汇总方案更符合真实业务场景。
3.2 为什么要把迟到早退状态在打卡时就算出来
这里分享一个我在开发过程中踩过的坑。最初我设计的打卡记录表里只存储打卡时间,考勤状态是在统计数据时才去判断的,结果就是各种查询条件组合在一起,SQL 越写越长,而且数据量大了后性能直线下降。后来我把状态判断逻辑前移,在员工打卡时就直接算好当天的考勤状态并写入记录表,统计报表时只需要做简单的 count 聚合,轻松很多。
同时要注意,考勤状态可能会被后续的请假申请影响。比如某位员工早上打了卡,但上午突发急事请了病假,那么理论上他当天的出勤状态可能需要修正。我的处理办法是:请假审批通过后,将请假时间范围内的打卡记录状态自动修正为“请假”,并在统计表中扣除相应的出勤天数。这属于业务规则的典型坑,提前设计好能避免后期手忙脚乱。
3.3 数据库脚本示例
下面给出员工表和打卡记录表的简化建表 SQL,供你参考:
CREATE TABLE `employee` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `emp_no` varchar(20) NOT NULL COMMENT '工号', `name` varchar(50) NOT NULL COMMENT '姓名', `department_id` bigint(20) DEFAULT NULL COMMENT '部门ID', `position` varchar(50) DEFAULT NULL COMMENT '职位', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `hire_date` date DEFAULT NULL COMMENT '入职日期', `status` tinyint(1) DEFAULT '1' COMMENT '1在职 0离职', PRIMARY KEY (`id`), UNIQUE KEY `uk_emp_no` (`emp_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表'; CREATE TABLE `attendance_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `employee_id` bigint(20) NOT NULL COMMENT '员工ID', `clock_date` date NOT NULL COMMENT '打卡日期', `clock_in_time` datetime DEFAULT NULL COMMENT '上班打卡时间', `clock_out_time` datetime DEFAULT NULL COMMENT '下班打卡时间', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1正常 2迟到 3早退 4异常', PRIMARY KEY (`id`), KEY `idx_employee_date` (`employee_id`, `clock_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='打卡记录表';这里有个关键细节:attendance_record表上一定要建立联合索引(employee_id, clock_date),因为绝大多数查询都是“查某个员工某段时间的记录”,没有这个索引的话,数据量稍微上来一点,查询就会明显变慢。这是我曾经用慢查询日志抓出来的教训,真的不能省。
4. 后端实现:Spring Boot 接口开发与考勤规则落地的核心代码
4.1 项目初始化与配置
创建 Spring Boot 项目时,我建议直接用 Spring Initializr,选好 Java 8 或 11(不要选太高版本,否则一些老依赖可能不兼容),勾选 Spring Web、MySQL Driver、MyBatis-Plus 依赖。MyBatis-Plus 需要单独添加依赖坐标,因为它不在 Spring Initializr 的列表里。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency>然后在application.yml里配置数据源和 MyBatis-Plus 相关参数:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/attendance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: id-type: auto其中map-underscore-to-camel-case一定要设为 true,这样数据库的clock_in_time字段就能自动映射到 Java 的clockInTime属性,省去一堆手写映射的麻烦。
4.2 员工管理模块的快速实现
员工模块属于典型的单表 CRUD,用 MyBatis-Plus 非常快。先建实体类:
@Data @TableName("employee") public class Employee { @TableId(type = IdType.AUTO) private Long id; private String empNo; private String name; private Long departmentId; private String position; private String phone; private LocalDate hireDate; private Integer status; }然后写 Mapper 接口,只需要继承BaseMapper:
@Mapper public interface EmployeeMapper extends BaseMapper<Employee> { }Service 层的核心逻辑也很简单,主要是对LambdaQueryWrapper的使用,比如按姓名或工号搜索:
public List<Employee> searchEmployees(String keyword) { LambdaQueryWrapper<Employee> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Employee::getName, keyword) .or() .like(StringUtils.hasText(keyword), Employee::getEmpNo, keyword); return employeeMapper.selectList(wrapper); }这一段逻辑虽然简单,但有两个经验值得说。一是LambdaQueryWrapper比QueryWrapper更安全,因为它是通过方法引用来获取字段名,编译时就能发现字段写错的问题。二是条件构造器里的hasText判断是必备的,否则前端传个空字符串过来,like会自动变成全表扫描的%%,把整个表的数据都查出来了,数据量大时很尴尬。
4.3 打卡接口的设计与实现
打卡是整个系统的核心接口,设计时要注意两点:一是要防止同一员工同一分钟重复打卡;二是要能准确判断迟到早退。
先定义考勤规则配置类:
@Data public class AttendanceRule { private LocalTime workStartTime; // 上班时间,比如 09:00 private LocalTime workEndTime; // 下班时间,比如 18:00 private Integer lateThreshold; // 迟到阈值(分钟),超过即算迟到 private Integer earlyThreshold; // 早退阈值(分钟) }打卡接口的核心逻辑:
public AttendanceRecord clockIn(Long employeeId, LocalDateTime clockTime) { // 防止重复打卡:当天已经打过上班卡则报错 LocalDate today = clockTime.toLocalDate(); AttendanceRecord record = attendanceMapper.selectOne( new LambdaQueryWrapper<AttendanceRecord>() .eq(AttendanceRecord::getEmployeeId, employeeId) .eq(AttendanceRecord::getClockDate, today) ); if (record != null && record.getClockInTime() != null) { throw new BusinessException("今天已经打过上班卡了"); } if (record == null) { record = new AttendanceRecord(); record.setEmployeeId(employeeId); record.setClockDate(today); record.setClockInTime(clockTime); // 判断是否迟到 if (clockTime.toLocalTime().isAfter(rule.getWorkStartTime().plusMinutes(rule.getLateThreshold()))) { record.setStatus(2); // 迟到 } else { record.setStatus(1); // 正常 } attendanceMapper.insert(record); } else { record.setClockOutTime(clockTime); // 判断是否早退 if (clockTime.toLocalTime().isBefore(rule.getWorkEndTime().minusMinutes(rule.getEarlyThreshold()))) { record.setStatus(3); // 早退 } attendanceMapper.updateById(record); } return record; }这段代码要注意一个细节:如果员工上午迟到后,下午正常下班,那最终状态应该保留“迟到”,下班打卡时不能把状态覆盖成正常。所以我只在clockOutTime为 null 时更新状态,否则保留原状态。类似的边界情况在实际开发中非常容易碰到,建议提前思考清楚。
4.4 考勤统计的逻辑实现
统计模块是我认为整个项目中最有技术含量的一部分。你需要处理应出勤天数、实际出勤天数、迟到次数、早退次数、缺勤天数、请假天数等多项指标。
我的实现思路是这样的:在每个月第一天(或月底),由定时任务触发生成上个月的统计汇总。统计的核心逻辑是:
public void generateMonthlySummary(String month) { // 1. 获取所有在职员工 List<Employee> employees = employeeMapper.selectList( new LambdaQueryWrapper<Employee>().eq(Employee::getStatus, 1) ); for (Employee emp : employees) { // 2. 计算应出勤天数(这里可以结合国家法定节假日,或者用工作日计算工具类) int workDays = calculateWorkDays(month, emp); // 3. 查询该员工当月打卡记录 List<AttendanceRecord> records = attendanceMapper.selectList( new LambdaQueryWrapper<AttendanceRecord>() .eq(AttendanceRecord::getEmployeeId, emp.getId()) .likeRight(AttendanceRecord::getClockDate, month) ); // 4. 统计各项指标 long lateCount = records.stream().filter(r -> r.getStatus() == 2).count(); long earlyCount = records.stream().filter(r -> r.getStatus() == 3).count(); long absentDays = workDays - records.size(); // 5. 写入汇总表 AttendanceSummary summary = new AttendanceSummary(); summary.setEmployeeId(emp.getId()); summary.setSummaryMonth(month); summary.setWorkDays(workDays); summary.setActualDays(records.size()); summary.setLateCount((int) lateCount); summary.setEarlyCount((int) earlyCount); summary.setAbsentDays((int) absentDays); attendanceSummaryMapper.insert(summary); } }这里面的calculateWorkDays是一个比较麻烦的方法。如果你不想引入节假日组件,可以先简单按“周一至周五为工作日”来计算,然后在需求说明或论文里注明“未考虑法定节假日”,这样在答辩时也不会被追问得太深。如果想做得更严谨,可以引入holiday-calendar之类的库,或者维护一张法定节假日表,这也是一个很好的加分项。
4.5 每月统计定时任务的实现方式
Spring Boot 提供了非常方便的@Scheduled注解来实现定时任务。在启动类或配置类上加上@EnableScheduling,然后在统计方法上加上@Scheduled即可。
@Component public class AttendanceSummaryTask { @Autowired private AttendanceSummaryService summaryService; // 每月1日凌晨2点执行上个月的统计 @Scheduled(cron = "0 0 2 1 * ?") public void generateLastMonthSummary() { LocalDate lastMonth = LocalDate.now().minusMonths(1); String month = lastMonth.format(DateTimeFormatter.ofPattern("yyyy-MM")); summaryService.generateMonthlySummary(month); } }cron 表达式是秒 分 时 日 月 周,所以0 0 2 1 * ?表示每月1日凌晨2点执行。这里要注意:cron表达式里的*和?不能混用错位,周字段如果不想指定,必须用?而不是*,这点已经被好多同学踩过坑了。定时任务触发的统计建议放在凌晨执行,避开员工打卡高峰,也避免大数据量统计影响数据库性能。
5. 前端页面与接口对接:Vue 打包部署到 Spring Boot 的那些事
5.1 Vue 项目核心页面设计
前端我采用 Vue 2 + Element UI 搭建。页面大方向就三类:登录与权限,员工管理页面,考勤记录页面。
员工管理页面用el-table展示员工列表,配合el-dialog实现新增和编辑功能,再用el-pagination做分页。这些是 Element UI 的基本用法,网上模板一抓一大把,我不重复讲了。这里重点说两个容易出问题的细节。
第一个是日期格式化问题。Element UI 的el-date-picker返回的默认格式是Date对象,如果你直接把它传给后端,JSON 序列化后可能是“2025-06-01T16:00:00.000Z”这种带时区格式,后端 LocalDateTime 直接解析会报错。我统一在 Axios 请求拦截器里对参数做处理,把日期统一格式化为yyyy-MM-dd HH:mm:ss字符串再提交:
axios.interceptors.request.use(config => { if (config.data instanceof FormData) { return config; } // 递归处理 Date 类型转字符串 config.data = JSON.parse(JSON.stringify(config.data, (key, value) => { if (value instanceof Date) { return dayjs(value).format('YYYY-MM-DD HH:mm:ss'); } return value; })); return config; });第二个是跨域问题。前后端分离开发时,前端跑在 9527 端口(Vue 默认),后端跑在 8080,浏览器会拦截跨域请求。最简单的本地解决办法是配置 Vue 的开发服务器代理,在vue.config.js里加上:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } };这样前端请求/api/employee/list时,实际上会被代理到http://localhost:8080/employee/list,完美避开跨域问题。当然线上部署时后端也要配置跨域或者使用 Nginx 反向代理,但开发阶段用 proxy 是最省事的。
5.2 把 Vue 打包后放进 Spring Boot 的方法与注意事项
这个需求在热词里出现了很多次,应该是很多同学在部署环节会卡住的问题。Spring Boot 默认是把前端当静态资源来处理的,不需要单独部署 Nginx,直接通过 Maven 把打包后的前端文件放进src/main/resources/static目录下就行。
具体流程是:
- 前端项目执行
npm run build,会生成dist目录。 - 把
dist目录下的所有文件复制到 Spring Boot 的src/main/resources/static/目录下。 - 重新打包 Spring Boot 项目,执行
mvn clean package。 - 启动 jar 包,浏览器访问
http://localhost:8080/index.html就能看到前端页面。
这里有两个细节千万注意。第一,Vue 2 默认的路由模式是 hash 模式,也就是 URL 上会带#,这种方式在打包部署时最省心,不需要额外配置。如果你用了 history 模式,刷新页面时后端会返回 404,必须在后端写一个 fallback 控制器,把未知路由指回index.html,操作比较繁琐。我的建议是能用 hash 就别用 history。
第二,如果前端和后端通过同一个域名端口访问,前端请求接口时建议用相对路径,比如axios.get('/api/employee/list')。如果把接口地址写死成http://localhost:8080/api/...,打包部署后就要改代码重新构建,非常麻烦。在前后端分离部署到同一 jar 的情况下,相对路径是唯一舒服的解法。
5.3 登录权限与拦截器实现
考勤系统不登录就说不过去。我使用的是 JWT 方案,实现方式很经典:用户登录成功后后端生成一个 token 返回给前端,前端把 token 存在本地,之后每次请求在 Header 里带上Authorization: Bearer <token>,后端通过拦截器校验 token。
Spring Boot 拦截器实现如下:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equals(request.getMethod())) { return true; // 放行预检请求 } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); try { // 解析 token,校验签名和过期时间 Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; } }最容易被忽略的是OPTIONS请求放行。当你在前端通过 Axios 携带自定义 Header 跨域请求时,浏览器会先发送一个 OPTIONS 预检请求,这个请求是没有 token 的,如果不放行,前端会一直报跨域错误。我当时排查这个问题花了好几个小时,所以这里特别提醒你。
同时,拦截器注册时要注意排除登录接口和静态资源路径,否则正常页面都进不去。在WebMvcConfigurer里这么写:
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/login", "/api/register", "/**/*.html", "/**/*.js", "/**/*.css", "/**/*.png", "/**/*.ico"); }6. 常见问题与排查技巧实录:这些坑我替你踩过了
6.1 Spring Boot 版本太高导致的依赖兼容问题
很多同学一上来就选最新版 Spring Boot,结果遇到各种诡异问题。比如 Spring Boot 3.x 必须搭配 Java 17 和 Jakarta EE,导致一堆旧教程里的javax.*包全部失效,网上资料又少,稍不留神就被卡住几天。
我的建议是:课程设计或中小型项目,直接用 Spring Boot 2.7.x 搭配 Java 8。这是经过大量生产环境验证的版本组合,资料最多、踩坑答案最全、面试官也熟悉。不要盲目追求新版本,项目的核心是完成业务,而不是追赶框架升级。
6.2 MyBatis-Plus 字段映射失败与 LocalDateTime 序列化问题
map-underscore-to-camel-case配置了但字段映射还是失败?最常见的毛病是实体类没有加@TableName注解,或者表名前缀不一致。另外LocalDateTime在 Jackson 序列化时默认格式是一长串数组,前端拿到根本没法显示,需要在配置中指定格式:
@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss") .serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) .deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }6.3 打卡记录查询慢?索引和分页一起上
当考勤记录表超过十万条时,不带条件的全表扫描会明显变慢。解决办法是确保组合索引生效,同时采用 MyBatis-Plus 的分页插件。分页插件配置很简单:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页插件不只是简单加 limit,它会在查询前先执行 count 查询,统计总条数。如果你觉得 count 查询性能有瓶颈,可以手动优化 count SQL,但一般业务场景够用了。另外一定记得查询条件里始终带上employee_id和clock_date区间,这样才能命中联合索引。
6.4 定时任务重复执行或时区错乱
如果部署到服务器后定时任务在“凌晨2点”执行,但实际日志时间差了8小时,大概率是服务器时区没设置成 Asia/Shanghai。在启动命令或配置里加-Duser.timezone=GMT+8,或者在application.yml中配:
spring: jackson: time-zone: GMT+8定时任务重复执行也很常见,尤其在集群部署时。课程设计一般不需要考虑分布式锁,但如果是在真实项目中,建议引入 Quartz 集群模式或者用 Redis 分布式锁,避免多个实例同时跑统计。
6.5 前端部署后刷新页面 404 的处理
如果你坚持用 Vue Router 的 history 模式,后果就是部署后访问非首页路径时刷新会 404。这个问题的本质是浏览器向 Spring Boot 请求了一个不存在的后端路由。解决方案是在后端加一个简单的控制器,把非/api开头的所有请求转发到index.html:
@Controller public class PageForwardController { @RequestMapping(value = "/{path:[^\\.]*}") public String forward() { return "forward:/index.html"; } }但要注意,这个控制器必须保证/**/*.js、*.css等静态资源优先被处理,否则静态文件也会被转发。所以放行规则要写严谨,或者干脆用 hash 模式,一了百了。
7. 项目演示与后续扩展建议
系统开发完成之后,建议准备一份演示脚本,按“登录→员工管理→打卡→请假审批→考勤统计”这条主流程走一遍。演示过程中可以刻意展示几个亮点:比如打卡后状态自动更新为迟到的效果,请假审批后统计报表随之变化,以及月度统计的定时任务可以手动触发并展示数据变化。这些环节能直观说明系统不是写死的假页面,而是真正有业务逻辑在跑的。
关于后续扩展,如果你还有余力或想要更高的完成度,我建议优先做这几件事:
- 引入 Redis 缓存,把部门列表、员工基础信息这类高频读取、低频更新的数据放进缓存,减轻数据库压力。
- 增加考勤异常申诉流程,员工可以对被误判的迟到早退发起申诉,管理员审核后修正状态。这个功能在真实业务里几乎是标配,写在简历上非常亮眼。
- 对接企业微信或钉钉的打卡数据,这个方向适合做毕设的同学,能体现你在接口对接方面的能力,不过难度会略微提升。
- 导出考勤报表到 Excel,用 Easy Excel 就可以实现,几十行代码搞定,却能让实用性提升一大截。
我个人在实际开发这类系统后最大的感受是:考勤系统技术上不算难,但它非常考验业务梳理能力。你能不能在动手前把规则想清楚、把边界场景列出来,直接决定了后面代码的稳定程度。如果你也正在做类似的 Spring Boot 项目,不用怕踩坑,遇到问题先确认是不是数据库设计或版本兼容导致的,再一步步排查。代码写丑了可以重构,数据库设计乱了才是真正的灾难。希望这篇内容能帮你把思路理顺,少走几个弯路。