去年我帮一家规模在百人左右的公司处理考勤问题,他们每个月月底靠Excel统计全公司的迟到、早退、请假记录,人事要花两天时间手工核对,还经常因为公式错乱导致数据对不上。做完那套基于SpringBoot+Vue的公司日常考勤系统之后,这个痛点基本消失了,月底统计从两天缩短到几分钟。本文我就把这套系统的设计与实现思路完整讲一遍,包括数据库设计、后端核心接口、前端页面、以及我在调试过程中踩过的坑,适合正在做Java全栈项目或者想自研内部管理系统的朋友参考。
1. 先搞清楚业务边界:考勤系统到底管哪些事
很多人在动手写考勤系统之前,容易陷入一个误区,一上来就打开IDE开始建表、写接口,结果做到一半发现业务规则没理清,返工成本非常高。考勤系统的核心其实不是技术,而是考勤规则本身。
1.1 需求清单:员工视角和管理员视角
考勤系统涉及两类角色,他们的需求完全不同。
员工端需要的能力相对简单:
- 每天上班签到、下班签退
- 查看自己当天的打卡状态(正常、迟到、早退、缺卡)
- 查看历史考勤记录和月度汇总
- 提交请假申请(事假、病假、年假等)
- 查看请假的审批进度
管理员端的需求才是系统的重头戏:
- 员工信息管理,包括入职、离职状态维护
- 设置考勤规则,比如上班时间、下班时间、宽限分钟数
- 查看全公司每天的考勤记录
- 审批员工的请假申请
- 按部门、按月份统计考勤汇总数据
这里要特别强调一个容易被忽略的点:考勤系统是给谁用的?表面上是员工打卡,但实际上真正依赖它的人是人事和部门主管。需求分析阶段一定要把自己代入人事的角色,把统计逻辑、异常处理这些内容放在核心位置,否则做出来的系统只是一个高级打卡机。
1.2 三个必须想清楚的状态流转
梳理需求时,我习惯把状态流转画清楚再动手。考勤系统必须明确三个核心状态机:
打卡状态:刚创建的记录是未打卡,上班打卡后变成已签到,下班打卡后变成已签退。根据打卡时间和规则计算出最终状态:正常、迟到、早退、缺卡。请假被批准后,当天的考勤状态直接标记为请假,不参与迟到早退统计。
请假审批状态:员工提交申请后是待审批,管理员审批后变成已批准或已驳回。这里有个业务细节:只有已批准状态才会影响到考勤统计,待审批和已驳回都不影响。如果员工请假期间包含了周末,周末不计入请假天数,这个也要提前定义清楚。
员工在职状态:入职、在职、离职。离职员工不再参与考勤统计,但历史考勤记录要保留。
MVP版本的边界切割也很重要。我在第一版里刻意没有做外勤定位、补卡申请、排班管理,因为这些功能会成倍增加复杂度。先把核心闭环跑通,后续再迭代。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:为什么偏偏是SpringBoot+Vue+MyBatis
这套技术栈在今天看来不算新潮,但非常适合考勤系统这种内部管理系统,每一项选择都有明确理由。
2.1 后端方案的取舍
SpringBoot作为后端基础框架,最大的价值在于自动配置和快速启动。开发考勤系统这种CRUD为主的业务,SpringBoot可以省掉大量XML配置,内嵌Tomcat让打包部署也简单很多。对比之前的SSM老三样,SpringBoot本质上是对Spring生态的封装,开发体验提升了一个档次。
MyBatis的选择比较关键。考勤系统的统计报表需要写大量复杂SQL,比如按部门分组统计迟到次数、按月份汇总出勤天数,MyBatis允许直接在XML里控制SQL,查询逻辑一目了然。如果用JPA,这类复杂聚合查询会变得很别扭,虽然也能写原生SQL,但和MyBatis比起来灵活性还是差一些。
有人说MyBatis是半自动ORM,要自己写SQL,但这恰恰是优势。就拿考勤统计来说,一条CASE WHEN嵌套的SQL就能把多个指标算完,换成JPA的Criteria查询,写出来的代码不仅冗长,可读性也差。
2.2 前端方案的取舍
Vue选择得非常自然。考勤系统有员工端和管理端两套界面,页面之间有很多公共组件,比如员工表格、日期选择器、状态标签,Vue的组件化机制让这些复用变得很轻松。
另一个重要原因是前后端分离的开发模式。前端用Vue的脚手架工具初始化项目,后端用SpringBoot提供纯JSON接口,两边并行开发,只要提前约定好接口格式就行。相比传统的JSP + Bootstrap写法,前后端分离的联调体验和代码可维护性都强很多。
2.3 工程结构:前后端分离如何组织
我的项目目录是这样组织的:
code复制attendance-system/
├── backend/ # SpringBoot 后端
│ ├── src/main/java/
│ │ ├── controller/ # 接口层
│ │ ├── service/ # 业务逻辑层
│ │ ├── mapper/ # MyBatis Mapper接口
│ │ ├── entity/ # 实体类
│ │ └── common/ # 通用响应、异常处理
│ └── src/main/resources/
│ ├── mapper/ # MyBatis XML文件
│ └── application.yml
└── frontend/ # Vue 前端
├── src/
│ ├── api/ # 接口封装
│ ├── router/ # 路由配置
│ ├── views/ # 页面组件
│ ├── components/ # 公共组件
│ └── store/ # 状态管理
└── package.json
这个结构谈不上惊艳,但非常务实。后端controller只做参数接收和响应封装,业务逻辑全部放在service层,mapper层只负责数据库访问。前端views放页面级组件,components放复用组件,api目录统一管理所有HTTP请求。分层清晰了,后期加功能才不会乱。
3. 数据库设计:考勤表的灵魂在每个时间字段
考勤系统的表结构并不复杂,但时间字段的设计稍有疏忽,后面统计报表就会出各种诡异的问题。
3.1 六张核心表的结构
我用六张表覆盖了考勤系统的全部数据存储需求。
sys_user:登录账号表,字段包括user_id、username、password、role(员工或管理员)、status。密码用BCrypt加密存储,这个不要偷懒。
sys_dept:部门表,字段是dept_id、dept_name。部门层级先不做递归树,第一版用单层结构就够了。
emp_employee:员工表,字段包括emp_id、user_id、dept_id、emp_name、position、phone、hire_date、status。员工ID和用户ID是1对1关系,考勤记录里存员工ID而不是用户ID,这样业务上更清晰。
att_rule:考勤规则表,字段包括rule_id、work_start_time、work_end_time、grace_minutes、effective_date。有效日期用于规则变更场景,比如公司从9点上班改成8点半上班,新增一条规则记录即可,历史考勤不受影响。
att_record:考勤记录表,这是全系统的核心表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| record_id | int | 主键 |
| emp_id | int | 员工ID |
| work_date | date | 工作日 |
| sign_in_time | datetime | 上班签到时间 |
| sign_out_time | datetime | 下班签退时间 |
| status | varchar(20) | 正常/迟到/早退/缺卡/请假 |
| remark | varchar(255) | 备注 |
att_leave:请假申请表,字段包括leave_id、emp_id、leave_type、start_date、end_date、reason、status、auditor_id、audit_time、audit_comment。
3.2 状态冗余:为什么在考勤记录里直接存"迟到/早退"
这里说一个我的设计习惯:考勤记录里的status字段是冗余的。理论上,迟不迟到完全可以通过sign_in_time和规则表计算出来,没必要直接存一个状态字段。
但我倾向于把状态直接算好存进去。原因是考勤统计的核心逻辑基本都围绕status做GROUP BY和CASE WHEN,如果每次查询都现场计算,SQL会变得极其复杂。冗余这个字段之后,月度统计直接对status做聚合就行,查询效率高得多,代码也简单得多。
代价是:打卡接口在写入记录时必须保证status计算正确,这个逻辑必须经过充分测试。迟到判断、宽限时间、请假状态覆盖,这些规则的优先级要理清楚。
3.3 唯一索引与数据约束
考勤记录表上必须加唯一索引uk_emp_workdate(emp_id, work_date),防止同一员工同一天插入多条记录。这是很多初写考勤系统的人容易漏掉的细节,不加唯一索引,并发请求下员工连续点击打卡按钮,就可能产生两条记录。
另外,sign_in_time和sign_out_time两个字段在业务上天然可以分离。一个设计决策是:当天只有签到没有签退时,sign_out_time为NULL,但当日必须有记录存在,这样页面才能展示"已签到,未签退"的状态。所以打卡接口要做成"查不到记录就INSERT,查到记录就UPDATE",而不是简单地每次插入一条新记录。
4. 后端实现:打卡、请假、审批、统计四个核心接口
后端接口我最关注四个:打卡接口、请假申请接口、审批接口、月度统计接口。这四个接口写好了,整个系统的骨架就立住了。
4.1 打卡接口:迟到早退怎么判断
打卡接口的核心逻辑有三步:查当天记录、写签到或签退时间、计算状态。我贴一段核心代码示意:
java复制@Service
public class AttendanceService {
@Resource
private AttendanceRecordMapper recordMapper;
@Resource
private AttendanceRuleMapper ruleMapper;
public AttendanceRecord signIn(Integer empId) {
LocalDate today = LocalDate.now();
AttendanceRecord record = recordMapper.selectByEmpIdAndDate(empId, today);
AttendanceRule rule = ruleMapper.selectEffective(today);
// 规则为空时必须处理,考勤规则没配置时直接拒绝打卡
if (rule == null) {
throw new BusinessException("当日考勤规则未配置,请联系管理员");
}
LocalTime now = LocalTime.now();
if (record == null) {
record = new AttendanceRecord();
record.setEmpId(empId);
record.setWorkDate(today);
record.setSignInTime(now);
record.setStatus(calcSignInStatus(now, rule));
recordMapper.insert(record);
} else {
if (record.getSignInTime() != null) {
throw new BusinessException("您今天已经签到过了");
}
record.setSignInTime(now);
record.setStatus(calcSignInStatus(now, rule));
recordMapper.update(record);
}
return record;
}
private String calcSignInStatus(LocalTime signInTime, AttendanceRule rule) {
LocalTime lateLimit = rule.getWorkStartTime()
.plusMinutes(rule.getGraceMinutes());
if (signInTime.isAfter(lateLimit)) {
return "迟到";
}
return "正常";
}
}
签退接口的写法类似,判断早退用下班时间减去宽限分钟作为阈值,在阈值之前签退算早退。这里有一个细节很多人搞错:宽限时间不是"上班9点,9点10分之内都算正常",而是上班时间加上宽限分钟后的那个时刻。如果上班9点、宽限5分钟,那么9:05:01开始签到就是迟到,9:05:00整不算,这个边界在比较时要算清楚。
4.2 请假申请与审批的状态机
请假申请的难点在于日期重叠校验。员工申请请假时,要检查申请的日期区间是否和已有的已批准请假存在重叠,否则一个员工同一天既请假又正常出勤,考勤统计必然乱套。
sql复制SELECT COUNT(*)
FROM att_leave
WHERE emp_id = #{empId}
AND status = '已批准'
AND start_date <= #{endDate}
AND end_date >= #{startDate}
审批接口特殊在事务性上。管理员点击"批准"后,系统要同时做两件事:更新请假单状态为已批准,并且把请假日期范围内的每一天的考勤记录状态标记为请假。这两个操作必须在一个事务里完成,否则可能出现请假单显示已批准,但考勤统计里仍然把请假当天算作缺卡的情况。
我遇到过这种情况:一开始只更新了请假单状态,忘了生成考勤标记,月底统计时某个员工被算成缺卡好几天,后来排查了很久才发现是审批流程的事务边界没包全。从那天之后我对涉及状态变更的接口都格外注意事务边界。
4.3 月度统计SQL:一条SQL算完所有考勤指标
月度统计是考勤系统里最有含金量的部分。我要统计的指标包括:应出勤天数、正常天数、迟到次数、早退次数、缺卡次数、请假天数。
核心SQL如下:
sql复制SELECT
e.emp_id,
e.emp_name,
d.dept_name,
COUNT(DISTINCT a.work_date) AS should_days,
SUM(CASE WHEN a.status = '正常' THEN 1 ELSE 0 END) AS normal_days,
SUM(CASE WHEN a.status = '迟到' THEN 1 ELSE 0 END) AS late_count,
SUM(CASE WHEN a.status = '早退' THEN 1 ELSE 0 END) AS early_count,
SUM(CASE WHEN a.status = '缺卡' THEN 1 ELSE 0 END) AS miss_count,
SUM(CASE WHEN a.status = '请假' THEN 1 ELSE 0 END) AS leave_days
FROM emp_employee e
LEFT JOIN att_record a ON e.emp_id = a.emp_id
AND a.work_date >= #{startDate}
AND a.work_date < #{endDate}
LEFT JOIN sys_dept d ON e.dept_id = d.dept_id
WHERE e.status = '在职'
GROUP BY e.emp_id, e.emp_name, d.dept_name
ORDER BY d.dept_name, e.emp_id
关于这个SQL有两个经验想分享。
第一,日期范围的右边界用< #{endDate}而不是<= #{endDate}。月底统计时传入的endDate是下个月1号,这样整个月的记录都包含在内,又不会误纳下个月的数据。用<=则必须精确计算到月末最后一毫秒,非常容易出错。
第二,核心表左连接考勤记录时,过滤条件要写在ON子句里而不是WHERE子句。写在WHERE里会把没有考勤记录的在职员工过滤掉,左连接就白做了。缺卡天数恰好就是那些没有考勤记录的员工需要重点关注的。
5. Vue前端:员工端到管理端的页面设计
前端页面我按角色拆成两块,员工端和管理端,很多新手把两套界面写在一起,导致权限混乱。实际上分开之后,每个角色的使用路径都非常清晰。
5.1 路由与权限:不同角色看到不同菜单
路由设计上,用Vue Router的全局前置守卫做登录判断和角色判断:
javascript复制router.beforeEach((to, from, next) => {
const user = localStorage.getItem('user')
if (to.path !== '/login' && !user) {
next('/login')
} else if (to.meta.role && to.meta.role !== user.role) {
next('/forbidden')
} else {
next()
}
})
菜单在侧边栏根据角色动态渲染。员工的菜单只有工作台、打卡、我的考勤、请假申请;管理员的菜单则包含员工管理、考勤管理、请假审批、统计报表。前端隐藏菜单只能解决使用体验问题,真正的权限控制还是依赖后端接口的拦截器校验,这个防线不能省。
5.2 打卡页的实现细节
打卡页是员工最常使用的页面,核心设计要求是:打开就能看到今天的打卡状态,点击大按钮完成打卡。
页面顶部用定时器实时刷新当前时间,我用的方式是:
javascript复制const now = ref('')
const updateTime = () => {
now.value = dayjs().format('YYYY-MM-DD HH:mm:ss')
}
setInterval(updateTime, 1000)
updateTime()
打卡按钮的状态要随着当天打卡进度变化:未签到显示"上班打卡",已签到未签退显示"下班打卡",两个时间都有就全部置灰显示"今日已完成打卡"。
这里有个交互细节需要注意:员工如果延迟上班,页面应该同时显示当前时间和应上班时间,让员工直观感受到自己是否已迟到。系统可以把这个判断做在前端,但最终记录必须以后端判断为准,前端的显示只是为了起到提醒作用。
5.3 管理端表格与筛选
管理端的考勤记录页面需要支持多维筛选:按日期范围、按部门、按状态。用了Element Plus的日期范围选择器、下拉选择和表格组件组合实现。
统计报表页我用了ECharts画柱状图展示各部门月度迟到率趋势。这个页面的数据接口和表格统计接口可以复用同一个后端统计服务,前端根据图表需求做二次聚合。部门月度迟到率的基本运算逻辑是:部门迟到总次数除以部门应出勤总天数,这个指标对人事判断部门管理状况很有参考价值。
6. 调试过程中踩过的坑:时区、事务、边界
这一部分全是实操中真实遇到的问题,我希望写出来能让后来者少走弯路。
6.1 MySQL时区错位导致"昨天"变成"今天"
现象:测试环境打卡后,数据库里记录的时间比实际时间多了8个小时。我排查了很久,从Java代码一路查到MySQL配置,最后发现是JDBC连接串和MySQL服务器的时区没有统一。
解决办法分两步。第一步,JDBC连接串显式指定:
code复制jdbc:mysql://localhost:3306/attendance?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8
第二步,MySQL服务器端的时区也统一设置,我是在MySQL配置文件中设置了default-time-zone = '+08:00'。
这个问题在本地开发时可能不出现,因为开发机通常和数据库在同一时区环境。但部署到云服务器之后,如果服务器默认时区是UTC,时间错位问题就会突然爆发。所以从一开始就统一时区,能省掉后面排查的大把时间。
6.2 审批事务的脏读问题
另一个踩过的坑是请假审批的脏读。最初我在一个方法里写了两次数据库操作,使用的Mapper方法还分别用了不同的事务管理方式,导致只有部分操作被事务包裹。当审批过程中某一步抛异常时,出现了请假单状态已经更新但考勤标记没有生成的脏数据。
我的解决办法是简化事务边界:在service方法上加@Transactional(rollbackFor = Exception.class),保证整个审批流程在一个事务里。同时把所有的Mapper调用都限定在这个service方法内部,不允许跨方法传播事务。
有一点需要提醒:事务加在service层是正确的,但要注意方法会被同类中的其他方法调用的场景。Spring的AOP事务代理对同类内部方法调用不生效,这是新手容易踩的坑。我这个项目没有嵌套调用,所以问题不大,如果你的代码里有内部调用,要么拆分成多个Bean,要么提公共方法。
6.3 月末统计时23:59:59的边界
还有一次遇到现象:月底统计时,最后一天晚上的签退记录没有计入当月数据,查明后发现是统计SQL用的是<= endDate,而endDate被设置成了月末最后一天的零时。
这里要用我前面提到的方案:所有统计接口统一采用左闭右开区间,用>= startDate AND < endDate。参数设计上,接口接收的endDate语义明确为"截止日期不含当天",这样就不会有人再把endDate传成月末最后一天了。代码层面我在日期参数校验上也做了统一处理和注释,防止后续维护的人理解偏差。
6.4 打卡记录并发去重的坑
还有一个容易被忽视的场景:员工快速连续点击打卡按钮,前端虽然做了按钮禁用,但网络延迟下依然可能发起多个请求。
后端靠两条防线兜底:一是数据库的唯一索引,二是接口内对已有记录查询的检查。但这里还有个细节,如果查不到记录时多个请求同时进来,两个请求都发现没有记录,然后都执行INSERT,唯一索引会拦住第二个插入并抛出异常。我需要把这个DuplicateKeyException统一捕获并转成业务提示,否则员工会看到一行看不懂的数据库报错。
7. 部署上线与后续扩展
7.1 前后端打包部署
部署流程比较简单。后端执行Maven打包命令生成jar包,前端执行npm run build生成静态文件,然后通过Nginx做反向代理。
Nginx的配置核心是location分发:
nginx复制server {
listen 80;
server_name attendance.example.com;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
root /opt/attendance/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
}
try_files $uri $uri/ /index.html;这行是为了支持Vue Router的history模式刷新时不出现404。MySQL的初始化脚本我用了一个手动执行的迁移SQL文件,第一版没有引入Flyway,如果后续迭代频繁,建议加上Flyway做数据库版本管理。
7.2 后续可以扩展的方向
第一版验证核心闭环之后,有几个方向很值得做。
补卡申请是刚需。员工偶尔忘打卡,完全靠管理员手工改库肯定不行,可以增加补卡申请+审批流程,审批通过后由系统修正考勤记录。
排班管理适合班次不固定的公司,把考勤规则从"全公司统一上下班时间"升级为"按员工按日期设置班次",这个改动对数据模型影响比较大,需要提前规划。
外勤定位打卡方面,可以在打卡接口接入地图定位SDK,记录打卡时的经纬度和详细地址。但这涉及员工隐私,上线前要充分沟通。
对接企业微信或钉钉也是一个实用方向。利用它们的免登能力替代自建登录,员工在办公软件里直接发起打卡或请假请求,用户体验会进一步提升。
写在最后的一点体会
考勤系统这类内部管理系统,技术上不算高深,真正的挑战在于把考勤规则理解透并把边界情况处理好。做完这套系统,我个人最大的感受是:业务规则比代码本身更值得花时间。SQL写错了可以改,规则没想明白返工的成本极高。如果你正准备开发类似的系统,建议动手前把考勤规则的每个分支都列出来,想想"如果员工在跨天加班的深夜打了卡怎么办""如果管理员忘了配置下个月考勤规则怎么办",这些看似偏门的场景往往决定了系统在验收时是锦上添花还是一地鸡毛。
