做考勤系统,听起来是个被做烂了的需求,但真上手才发现,能把"打卡"这个动作从Excel表格里捞出来,做成一套能抗住公司日常运转的小系统,涉及的细节远比你想象的多。我这次用SpringBoot+Vue+MySQL+MyBatis做了一套完整的公司日常考勤管理系统,从后端接口到前端页面,从数据库建表到请假审批的状态流转,全部自己写了一遍。这篇文章就当作一份踩坑实录,把我设计这套系统时的思路、关键模块的实现方式、以及上线后遇到的那些典型问题一次性交代清楚。
如果你正在准备毕业设计,或者在小公司里被Excel考勤折磨过,再或者只是想看一套前后端分离的Java全栈项目到底该怎么组织代码,这篇东西应该能帮你省下不少时间。
1. 项目概述与需求拆解
1.1 考勤管理的"表面需求"和"真实痛点"
很多没做过考勤系统的人,第一反应就是把打卡记录存进数据库,再做个列表展示出来。这确实是表面需求,但真正的痛点根本不在这。
我接到这个需求时,对方公司大概五六十人,之前用的方式是:打卡机导出的Excel + 人事手工标记每日异常 + 月底统一核算。问题集中在三个地方:一是员工请假的申请单和审批记录散落在微信里,月底核对时纯靠人事的记忆去补;二是打卡机只能记录"几点到几点走",无法处理外勤、漏打、加班冲抵这些复杂情况;三是月底核算时,Excel公式满天飞,稍微拖错一个单元格就能让整个月的工资表对不上。
所以这套考勤系统的真实目标,不是简单记录打卡数据,而是要把_请假、加班、外勤、补卡、自动统计_这五件事变成一个闭环流程。每个员工每天只要面对一个页面:早上打卡一个按钮,下午打卡一个按钮,遇到特殊情况在系统里提交申请就行。月底人事打开统计报表,所有数据已经自动算好,要做的只是复核而不是重新核算。
1.2 功能模块与角色权限拆分
功能设计上,我按角色把系统拆成三块:员工端、管理端、系统端。
先看角色。员工就两类:普通员工和管理员(考勤专员/部门主管)。普通员工需要的能力是:上下班打卡、查看自己当月考勤日历、提交请假/加班/补卡申请、查看申请审批状态。管理员需要的能力是:查看全员当日考勤状态、审批各类申请、按部门和日期筛选考勤记录、查看月度统计报表、维护员工信息与部门结构。
再看核心模块,总共六块:
- 考勤打卡模块:上班卡、下班卡、打卡时间记录、迟到早退判定
- 请假管理模块:事假、病假、年假等类型,申请后走审批流
- 加班管理模块:加班申请、审批、加班时长核算
- 补卡管理模块:漏打卡后的补卡申请与审核
- 统计分析模块:月度出勤天数、迟到次数、请假时长、加班时长自动汇总
- 系统管理模块:员工账号管理、部门管理、班次规则配置
这里有一个关键设计思路:考勤系统的核心不是打卡按钮,而是_规则引擎_。迟到多少分钟算迟到?一天打几次卡?请假需要哪级审批?这些规则如果写死在代码里,后续调整班次就会很痛苦。我把规则配置做成数据库表,管理员可以在后台修改上下班时间、迟到容忍分钟数、免打卡天数等参数,系统代码只负责执行规则,不硬编码具体时间值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型解析:为什么是SpringBoot+Vue+MyBatis
2.1 后端选型:SpringBoot的"约定优于配置"有多大价值
选SpringBoot,说白了就一句话:省心。我早年写过SSH那套配置,光XML配置就能折腾一下午。SpringBoot通过自动配置和起步依赖把大部分基础设施都封装好了,我只需要关注业务代码。尤其是SpringBoot的starter机制,引入spring-boot-starter-web、spring-boot-starter-validation、mybatis-spring-boot-starter这些依赖之后,项目的起点是一个干净的Main类,跑起来就能看到接口文档。
对于考勤系统这种典型的CRUD密集型业务,SpringBoot提供的东西刚好够用:RestController做接口暴露、Service层配事务管理、AOP做统一日志和异常捕获。这些能力看起来普通,但组合起来之后,代码的可维护性比传统Servlet那套高一个量级。
2.2 持久层选型:MyBatis的灵活SQL是报表统计的王牌
ORM选型上,JPA和MyBatis都能做考勤系统,但我坚持用MyBatis。原因是考勤统计报表的SQL特别复杂:按部门分组统计,按天聚合,多表关联,还需要处理NULL值转0这种细节。JPA的JPQL虽然能写,但面对复杂查询时写起来很别扭,而且调优时需要关注生成的SQL,反而不如直接用MyBatis写SQL来得直观。
MyBatis的另一个好处是Mapper接口和XML分离,SQL和Java代码解耦。比如"月度统计"这个功能,我需要查每个员工整个月的出勤汇总,SQL里有case when条件判断、LEFT JOIN、日期函数组合。用MyBatis写XML里的SQL,我可以直接测试这段SQL的正确性,再填上动态条件。这种调试体验是JPA给不了的。
2.3 前端选型:Vue2+Element UI的成熟生态
前端我用的是Vue2,搭配Element UI。不是没考虑过Vue3,但考虑到这套系统的维护成本和很多开发者对Vue Options API的熟悉程度,Vue2的生态在这个场景下足够稳定。Element UI的表格、日期选择器、表单验证组件开箱即用,特别是el-table的筛选和排序,考勤列表页直接用这些组件可以省掉大量手写DOM的工作。
前端的架构很简单:Vue Router做页面路由,Axios做HTTP请求,Vuex做全局登录状态管理。页面按模块建views目录:dashboard、attendance、leave、overtime、statistics、system。每个模块配一个api.js文件统一封装接口调用。
3. 数据库设计:整个系统的地基
3.1 六张核心表的设计思路
考勤系统的数据库设计,核心是_把"记录"和"规则"分开存_。记录是每天发生的实际打卡数据,规则是系统判断依据。我设计了六张核心表:部门表、员工表、考勤班次表、打卡记录表、请假申请表、加班申请表。
部门表和员工表不用多说,两个表通过外键关联。班次表存上下班时间规则,比如上午9点上班、下午6点下班。打卡记录表存每一次打卡的时间戳、类型(上班/下班)、定位地点、设备信息。请假和加班审批表存申请信息、审批状态。
数据库设计上有个容易踩的坑:不要把"应出勤天数"、"迟到次数"这种统计结果直接存在主表里。数据在不断变化,统计结果应该是_通过SQL实时计算或者定时任务生成_,存一份快照只是为了查询快,不能作为唯一依据。
3.2 打卡记录表的核心字段说明
打卡记录表的设计,是整个数据库里最需要抠细节的部分。先看建表SQL:
sql复制CREATE TABLE `t_attendance_record` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`employee_id` bigint(20) NOT NULL COMMENT '员工ID',
`attendance_date` date NOT NULL COMMENT '打卡日期',
`check_in_time` datetime DEFAULT NULL COMMENT '上班打卡时间',
`check_out_time` datetime DEFAULT NULL COMMENT '下班打卡时间',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0-正常,1-迟到,2-早退,3-缺卡,4-外勤',
`location_desc` varchar(255) DEFAULT NULL COMMENT '打卡地点描述',
`work_type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0-工作日,1-周末加班,2-节假日加班',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_employee_date` (`employee_id`,`attendance_date`),
KEY `idx_attendance_date` (`attendance_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='打卡记录表';
这个设计的精妙之处在于 UNIQUE KEY uk_employee_date。一个员工一天只能有一条记录,上班卡和下班卡都更新在同一条记录上。这样设计方便统计,查询某个人某天的情况只需要一条SQL搞定。但要注意的是,打卡动作是分两次的,早上插入一条记录,晚上再update这条记录的check_out_time。如果员工早上打了卡、下班忘了打,那check_out_time就是NULL,系统自动判定为缺卡,这个逻辑在服务端处理。
status字段保存的是系统自动计算的当日考勤状态,但这里有个经验:状态字段不要存储"迟到多少分钟"这种具体值,只存状态类型。具体迟到分钟数如果需要审计,可以通过计算check_in_time和班次表的上班时间差值得出,不需要冗余存储。
3.3 请假与审批的状态流转设计
请假表的设计同样有讲究。先看核心字段:
sql复制CREATE TABLE `t_leave_apply` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`employee_id` bigint(20) NOT NULL COMMENT '申请人ID',
`leave_type` tinyint(4) NOT NULL COMMENT '1-事假,2-病假,3-年假,4-调休',
`start_time` datetime NOT NULL COMMENT '开始时间',
`end_time` datetime NOT NULL COMMENT '结束时间',
`duration_days` decimal(10,1) NOT NULL COMMENT '请假时长(天)',
`reason` varchar(500) DEFAULT NULL COMMENT '请假事由',
`approver_id` bigint(20) DEFAULT NULL COMMENT '审批人ID',
`approve_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0-待审批,1-已通过,2-已驳回,3-已撤销',
`approve_comment` varchar(500) DEFAULT NULL COMMENT '审批意见',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='请假申请表';
审批状态用数字表示,0-待审批、1-已通过、2-已驳回、3-已撤销。这张表的状态变更是一个_状态机_:待审批可以流转到已通过、已驳回、已撤销三态;已通过和已驳回是终态,不能再动;已撤销只能从待审批状态发起。这种设计需要在Service层加状态校验,不能只在前端控制按钮。
加班表的设计结构和请假表类似,区别在于加班需要记录加班的起始时间和结束时间,最后算出加班分钟数。这里有个细节:加班往往是当天发生的,更像是一个"事后补录"或"提前申请"的流程。员工如果当天加班,可以在系统里申请,审批通过后加班时长进入统计。
4. 后端核心实现:从登录到统计的完整链路
4.1 登录鉴权:用JWT还是Session
考勤系统的用户量不大,对并发要求不高,用Session完全够用。但我还是选了JWT,原因不是性能,而是_前后端分离架构下的扩展性_。用JWT后,后端不需要维护Session状态,登录接口返回一个token,前端把token存到localStorage,之后每次请求在Authorization header里带上来,后端用一个拦截器校验token合法性。
实现上,我用了jjwt这个库。登录逻辑很简单:接收用户名密码,验证账号密码正确后,用secret生成token,token里带上用户ID和角色信息。拦截器里从token解析出用户ID,放到ThreadLocal里,后续接口从ThreadLocal获取当前登录用户。
这里有个容易忽略的点:密码安全。不管是不是毕业设计,只要系统可能上线,密码都不能明文存。我用的是BCrypt加密,用它自带的方法做校验。build系统时别在这上面图省事。
4.2 打卡接口:服务端时间才是"正确时间"
打卡接口是考勤系统的门面,也是最容易出问题的点。很多初学者会把时间交给前端传过来,前端用Date.now()生成时间,然后POST到后端。这样做有个隐患:客户端时间和服务器时间可能不一致,用户可以修改本地时间提前打卡。所以我的设计是,后端完全忽略前端传的时间,打卡时间以服务器收到请求的时间为准。
打卡接口的核心逻辑是:
java复制public ResultVO punchCard(Long employeeId, Integer punchType) {
LocalDate today = LocalDate.now();
LocalTime now = LocalTime.now();
AttendanceRecord record = attendanceRecordMapper.findByEmployeeIdAndDate(employeeId, today);
if (record == null) {
record = new AttendanceRecord();
record.setEmployeeId(employeeId);
record.setAttendanceDate(today);
record.setCheckInTime(LocalDateTime.now());
// 判断是否迟到
ShiftConfig shift = shiftConfigMapper.findCurrent();
if (now.isAfter(shift.getWorkStartTime().plusMinutes(shift.getLateTolerance()))) {
record.setStatus(AttendanceStatus.LATE);
}
attendanceRecordMapper.insert(record);
} else {
if (punchType == PUNCH_TYPE_OUT) {
record.setCheckOutTime(LocalDateTime.now());
// 判断是否早退
if (now.isBefore(shift.getWorkEndTime().minusMinutes(shift.getEarlyTolerance()))) {
// 如果之前状态是正常,改成早退;如果之前是迟到,保持迟到并叠加早退状态
}
attendanceRecordMapper.update(record);
}
}
return ResultVO.success(record);
}
注意到一个细节:打卡接口需要_幂等性_。如果前端网络抖动,重复请求了两次上班打卡,后端不能插入两条记录。我上面的实现靠数据库唯一索引employee_id + attendance_date来控制,即使后端方法被重复调用,第二次会走update路径或者直接报唯一键冲突,不会产生脏数据。这里需要配合异常捕获,把唯一键冲突转成友好提示,而不是直接抛出500。
判断迟到早退的逻辑,我额外加了一个"容忍时间"参数。班次表里有lateTolerance字段,默认是5分钟。也就是说,如果规定9点上班,员工9:03打卡不算迟到,9:06才算。别小看这5分钟,它是处理一些偶发失误的缓冲。
4.3 考勤统计:一条SQL做完月报
月度统计是管理员最需要的功能,也是SQL最复杂的地方。这里有个典型的统计需求:输入年份月份和部门ID,返回每个员工这个月的应出勤天数、实际出勤天数、迟到次数、早退次数、缺卡次数、请假总天数、加班总时长。
我的实现思路是:先查员工基础信息,再用LEFT JOIN关联打卡记录、请假记录、加班记录,最后用GROUP BY按员工分组聚合。虽然SQL长,但比用Java在内存里分批计算要清晰得多。核心是这段:
sql复制SELECT
e.id AS employee_id,
e.name AS employee_name,
COUNT(DISTINCT CASE WHEN ar.id IS NOT NULL THEN ar.attendance_date END) AS actual_days,
SUM(CASE WHEN ar.status = 1 THEN 1 ELSE 0 END) AS late_count,
SUM(CASE WHEN ar.status = 2 THEN 1 ELSE 0 END) AS early_leave_count,
SUM(CASE WHEN ar.status = 3 THEN 1 ELSE 0 END) AS miss_card_count,
COALESCE(SUM(la.duration_days), 0) AS leave_days,
COALESCE(SUM(ot.overtime_minutes / 60.0), 0) AS overtime_hours
FROM t_employee e
LEFT JOIN t_attendance_record ar
ON e.id = ar.employee_id
AND ar.attendance_date BETWEEN #{monthStart} AND #{monthEnd}
LEFT JOIN t_leave_apply la
ON e.id = la.employee_id
AND la.approve_status = 1
AND la.start_time BETWEEN #{monthStart} AND #{monthEnd}
LEFT JOIN t_overtime ot
ON e.id = ot.employee_id
AND ot.approve_status = 1
AND ot.start_time BETWEEN #{monthStart} AND #{monthEnd}
WHERE e.department_id = #{deptId}
GROUP BY e.id, e.name
这段SQL有几个需要留神的地方。一是 COUNT(DISTINCT CASE WHEN ... END) 这个写法,如果打卡记录表里一条员工一天有多条打卡记录(实际上我们的表结构避免了这个情况,但为了保险起见还是用了DISTINCT),不会重复统计。二是LEFT JOIN保留未打卡的员工,比如新入职但还没有任何打卡记录的人,实际出勤天数就是0。三是COALESCE处理NULL值,NULL在聚合函数里默认被忽略,但不转为0的话前端显示会是空,不方便。
统计接口还需要做_分页_。我用PageHelper插件,在查询前一行PageHelper.startPage(pageNum, pageSize),查询后自动返回PageInfo。这里有个PageHelper的经典坑:它基于ThreadLocal实现,必须在Mapper查询前调用startPage,并且同一个线程里只能有一个查询。如果你的mapper XML里有多个查询方法连续调用,第二条查询会走到自己的查询条件里,可能查不到正确结果,甚至分页失效。解决方式是为统计查询单独写一个mapper方法,不和其他查询混在同一个Service事务里。
4.4 请假审批:状态机与事务边界
请假审批这个功能,看起来就是"提交-审批-改状态",但真正做的时候会发现_状态校验_是个很容易漏的地方。比如员工A提交了请假申请,管理员B正在审批页面看这个申请,这时员工A把申请撤销了,管理员B再点"通过",这时候系统应该怎么办?
我的实现是在Service层加一个状态校验:
java复制@Transactional(rollbackFor = Exception.class)
public void approveLeave(Long applyId, Long approverId, Integer approveStatus, String comment) {
LeaveApply leave = leaveApplyMapper.selectById(applyId);
if (leave == null) {
throw new BusinessException("申请不存在");
}
if (leave.getApproveStatus() != LeaveStatus.PENDING) {
throw new BusinessException("当前申请状态不是待审批状态,无法操作");
}
// 更新状态
leave.setApproveStatus(approveStatus);
leave.setApproverId(approverId);
leave.setApproveComment(comment);
leaveApplyMapper.updateById(leave);
}
这个方法标记了@Transactional,保证状态更新是一个原子操作。这里还有另一个细节:审批通过之后,如果请假时间包含法定节假日怎么办?我的处理是_在统计时不区分工作日和节假日,只按自然日计算请假天数_,但在界面上会提示员工请假区间跨越了周末或节假日,由审批人人工判断是否合理。这个设计比较务实,因为小公司很难把法定节假日规则完全自动算对,人工辅助反而更准确。
5. 前端页面与交互实现
5.1 页面布局与路由权限控制
前端我采用的是经典管理后台布局:左侧菜单栏 + 右侧内容区。路由设计上,普通员工和管理员看到的菜单不同。管理员能看到"考勤管理"、"审批管理"、"月度统计"、"系统设置"这四个菜单;普通员工只能看到"我的打卡"、"我的申请"、"我的统计"。
路由权限控制,我用的是最简单也最可靠的方案:路由守卫 + 动态菜单渲染。在Vue Router的beforeEach守卫里,根据localStorage里存的用户角色,判断当前路由是否在允许的权限列表里,不在就重定向到"我的打卡"页。菜单初始化时,根据角色动态生成menuItems数组,v-for渲染到el-menu上。
这个方案虽然有个缺点:前端路由守卫只能防住"手滑用户",不能防住"恶意用户"通过接口绕过页面直接调用后端API。所以后端接口层面必须有对应的权限校验(用拦截器判断角色),前端只是提升用户体验,真正的安全防线在后端。
5.2 打卡页面:定位和按钮状态的坑
打卡页面的交互设计其实很简单:一个大的打卡按钮,上面显示当前时间和打卡状态。但这里有一个容易踩坑的地方:打卡按钮的状态要随服务器时间动态更新,而不是完全依赖前端本地时间。我的处理是,页面加载时调用后端接口获取服务器当前时间,然后用setInterval每5秒同步一次,显示在页面上。打卡按钮的可用性判断(比如下午5点半之后才能打下班卡)也基于服务器时间。
再就是定位功能。打卡时记录当前位置,我用的浏览器Geolocation API,把经纬度传给后端,后端只存储展示,做"外勤打卡"时使用。这里有个坑:浏览器定位在办公室内经常失败,因为GPS信号弱。我的处理是:定位失败时允许手动填写地点描述,管理员在后台审核打卡记录时可以附加备注说明。打卡系统的原则是_记录事实,而不是强制拦截_。
前端打卡调用的逻辑如下:
js复制submitPunch() {
const punchType = this.isCheckIn ? 1 : 2
api.punchCard({ punchType }).then(res => {
if (res.code === 200) {
this.$message.success(this.isCheckIn ? '上班打卡成功' : '下班打卡成功')
this.fetchTodayStatus()
} else {
this.$message.error(res.msg)
}
}).catch(() => {
this.$message.error('网络异常,请重试')
})
}
这里特别要注意防止重复点击。我加了一个isSubmitting标志位,点击后置为true,请求完成或失败后再改为false。不然用户连点两次,虽然后端唯一索引挡住了重复数据,但会收到一个500错误提示,体验很差。
5.3 统计报表与Excel导出
月度统计页我用了Element UI的el-table,列比较多:员工工号、姓名、部门、出勤天数、迟到次数、早退次数、缺卡次数、请假天数、加班时长、备注。表格支持按部门维度筛选,后端返回分页数据后前端直接渲染。
Excel导出这里,不同网上那些花哨的方案,我直接用了后端Apache POI生成xlsx文件,前端用window.open直接下载后端返回的文件流地址。这样做的好处是:数据量大的时候导出不依赖前端内存,报表格式由后端统一控制。
POI导出有一个经典坑:xlsx文件中文列头乱码。需要在创建cell时设置编码格式没问题,但文件下载时ResponseHeader里的Content-Disposition需要使用URLEncoder编码中文文件名。我最初没加这层编码,导出的文件名全是乱码,后来用:
java复制String fileName = URLEncoder.encode("月度考勤统计.xlsx", "UTF-8");
response.setHeader("Content-Disposition", "attachment; filename=" + fileName);
才解决。另外POI对大数字的默认处理是科学计数法,在导出员工工号时,如果工号是纯数字长串,Excel会显示成1.23457E+10。解决方式是创建cell时设置setCellType(STRING),或者干脆把工号字段在SQL里转成varchar再导出。
6. 常见问题与排查技巧实录
6.1 跨域问题
前后端分离开发时最典型的问题是跨域。前端跑在8080端口,后端跑在8081端口,浏览器直接请求后端接口时会被CORS拦截。我最初的方案是在后端SpringBoot加一个@CrossOrigin注解,结果发现有的请求成功了有的失败了,排查半天才发现问题不在于跨域策略,而在于我有些接口的请求方式是从POST改成PUT后没有更新前端调用代码。后来我统一做了一层CORS配置类,全局生效:
java复制@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
}
用全局配置的好处是,新加接口时不需要逐个加注解,只要路径匹配就会自动带上跨域允许头。这是个"配置一次,长期受益"的操作。
6.2 MyBatis mapper扫描不到的排查
项目里包结构稍微乱一点,就会遇到Mapper接口可以编译通过,但运行时报错"Invalid bound statement (not found)"。这个问题的根源是Mapper接口和Mapper XML文件没有正确绑定。我用的方式是:启动类上加@MapperScan("com.example.attendance.mapper"),指定到mapper接口所在的包;同时确保resources/mapper目录下XML的namespace属性和接口全限定名一致。
这里有个很重要的细节:SpringBoot不会自动扫描resources目录下的XML文件。即使你的XML文件存在,如果类型没有指定好也会报错。最保底的排查方式是在application.yml里显式配置:
yaml复制mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.attendance.entity
配置完如果还扫不到,检查target目录里的classes文件夹,看一下mapper资源是否被编译进去了。有时候IDE没有执行mvn clean,旧的class文件和XML残留,会出现"改代码不生效"的假象。
6.3 打卡记录重复与唯一键冲突
前面说了,打卡接口依赖唯一索引保证幂等。实际运行中,确实遇到过员工狂点打卡按钮,前端虽然加了isSubmitting标志位,但还是有极端情况(比如手速特别快、按了两次回车),后端收到两次打卡请求。第二次插入时MySQL抛出DuplicateKeyException,如果不做处理,用户看到的就是500错误。
我的处理方式是,在打卡Service里catch这个异常,然后通过selectByEmployeeIdAndDate查询已有记录,返回"今日已打过卡"的提示。这个兜底逻辑很小,但很提升使用体验。经过这个坑,我意识到:任何唯一索引约束的插入操作,都应该配套一个异常转友好的处理逻辑。数据库层面保证了不脏,应用层负责让错误提示更好看,两者缺一不可。
6.4 时区问题:服务器时间比本地晚8小时
部署的时候如果服务器配置了UTC时区,MySQL默认的CURRENT_TIMESTAMP会存成比本地时间慢8小时的日期。打卡记录里出现了"早上8点打卡,系统显示凌晨0点"的诡异现象。
排查后确认是JVM时区、MySQL时区、连接参数这三者的混乱配置造成的。统一方案是:JVM启动参数加-Duser.timezone=Asia/Shanghai,MySQL连接URL加useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,数据库初始化时执行set global time_zone = '+8:00'。三个地方都设成一致的时区,问题就再没出现过。顺便一提,如果用了default CURRET_TIMESTAMP的列,这个修改要重启MySQL才能生效。
6.5 前端el-table大数据量渲染卡顿
月度统计报表如果一次查2000条记录,el-table渲染起来会有明显卡顿,特别是页面里还有其他图表组件时。测试中确实遇到这个问题,当时两条路:一是分页,二是虚拟滚动。考勤统计报表天然适合分页,后端已经按pageSize分页返回,前端只需要在el-table上配置pagination组件,数据量的问题就解决了。但管理员看报表时更喜欢_一屏看全月_,所以我加了一个"下一页"和"跳页"功能,同时把默认pageSize设为50,算是平衡了体验。
如果不想用分页,也可以尝试表格懒加载和虚拟列表,但没必要为了一个几十人到几百人的考勤场景增加复杂度。
7. 打通整个系统:部署与初始化流程
7.1 本地开发环境的基本配置
整套系统跑起来的必备软件依赖是:JDK 8+、MySQL 5.7+、Maven 3.6+、Node.js 14+。后端我用IDEA打开maven项目,等待依赖下载完成;前端用VSCode打开项目目录,执行npm install安装依赖。
MySQL需要手动创建数据库:
sql复制CREATE DATABASE IF NOT EXISTS attendance_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
然后在application.yml里配置数据源:
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/attendance_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: 你的密码
启动后端项目后,前端npm run dev启动,访问http://localhost:8080,后端接口地址在vue.config.js里配置代理转发的目标。
js复制devServer: {
proxy: {
'/api': {
target: 'http://localhost:8081',
changeOrigin: true,
pathRewrite: { '^/api': '' }
}
}
}
这个代理配置很关键。它解决的不只是跨域问题,还让前端的请求路径显得更统一。生产环境部署时,这个代理配置换成Nginx的反向代理配置即可。
7.2 初始化数据与默认账号
系统安装后需要初始化数据才能正常使用。我的sql脚本里预置了管理员账号admin/123456,以及一个测试员工账号test/123456。同时初始化了一个默认班次:9:00-18:00,午休12:00-13:30,迟到容忍5分钟,早退容忍5分钟。这些初始值都写进了schema.sql,导入数据库后即可使用。
初始化数据时注意一个细节:密码必须用BCrypt加密后的密文,不能直接INSERT明文密码。我在脚本里预置的是预先用工具生成好的BCrypt哈希值,这样系统第一次启动就能直接登录,不用额外写一个注册接口。
7.3 项目打包与部署
后端打包用mvn clean package,生成jar包,然后java -jar attendance-system.jar启动。前端打包用npm run build,生成dist目录,把dist目录里的静态文件交给Nginx托管,然后Nginx配置/api路径的反向代理指向后端端口。
这里的核心配置是:
nginx复制server {
listen 80;
server_name localhost;
root /usr/share/nginx/html/dist;
index index.html;
location /api/ {
proxy_pass http://localhost:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
注意proxy_pass后面没有带路径,这意味着请求"/api/attendance/list"会原样转发给后端8081端口,后端接口的RequestMapping要包含/api前缀,或者前端所有请求都以/api开头、后端Controller也以/api开头映射。如果这里路径处理不小心,会出现404。
8. 常见问题排查与避坑速查表
做这套系统下来,我把踩过的坑汇总成了一张速查表,遇到问题先对着查。
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 登录报401,接口被拦截 | token过期或未传Authorization头 | 前端拦截器统一附加token,后端拦截器排除login接口 |
| 打卡成功但统计报表无数据 | 统计SQL关联条件错误或日期范围不对 | 检查Mapper XML里的BETWEEN条件,确认日期格式是yyyy-MM-dd |
| 导出Excel文件名乱码 | Content-Disposition未做URL编码 | URLEncoder.encode中文文件名 |
| 管理员无法审批 | 权限拦截器放行路径没包含审批接口 | 在后端权限校验白名单里加审批路径 |
| 新增部门后员工下拉框无数据 | 前端缓存或后端接口前缀不匹配 | 清缓存,检查接口路径是否带了/api |
| 打卡时间晚8小时 | JVM/MySQL时区不一致 | 统一设置timezone为Asia/Shanghai |
这条速查表在开发中反复帮了我大忙,建议有类似项目的人提前核对。
9. 最后做完这套系统,我悟到的几个道理
考勤系统做完之后,我最大的体会是:工程难度不在代码量,而在边界情况的处理。比如重复打卡、时区错乱、请假撤销后再审批、补卡申请和原始打卡记录的联动、拉取统计数据时遇到离职员工的历史记录……这些边角场景,每个单独拎出来都不难,但它们组合在一起的时候,最容易让人手忙脚乱。
另外一个体会是:系统设计要留出"容错口子"。考勤的本质是记录事实和裁决规则,但事实可能模糊,规则也可能要调整。所以我在系统里加了"补卡申请"和"人工备注"两个功能,给管理者留一条绕过自动规则的手动修正通道。这不是偷懒,而是很多真实业务场景本身就有灰色地带。
如果你准备基于这套代码做二次开发,建议先看一下三个地方:数据库初始化脚本里的班次规则表、后端统计接口的SQL、前端打卡页面的时间同步逻辑。这三个是整套系统的骨架,把这三块看懂,你自己的扩展需求基本都能找到切入点。
还有一点想强调的,不管你是为了毕业设计还是公司实际需求,别一上来就惦记着加各种炫酷功能。把打卡、请假、统计这三条主链路跑通,再考虑引入人脸识别、钉钉消息推送这些外围能力。核心没跑通之前,多一个功能就多一分排查问题的负担。做考勤系统这件事,稳定压倒一切。
