简介:这是一份基于 SSM(Spring+SpringMVC+MyBatis)与 JSP 技术栈的员工考勤管理系统,面向高校毕业设计、课程设计及 Java Web 初学者,可帮助读者快速搭建并理解企业级分层开发的项目骨架。系统按一般员工、部门经理、系统管理员三种角色划分权限,覆盖个人资料、上班时间公告、请假、出差、差费报销、考勤及日常出勤等核心模块,管理员还可进行系统用户、部门、员工管理以及员工/请假统计,业务链路完整。压缩包为 zip 格式,约 29.96MB,内含可运行源码、SQL 数据库文件与配套说明文档,可直接部署运行。已有 1847 人学习浏览,适合作为项目实战参考、答辩演示或课程作业的完整方案。通过源码与 SQL 对照,可掌握 SSM 整合配置、JSP 页面开发、角色权限控制及考勤数据统计等关键技能。
1. 这套公司员工考勤管理系统,为什么还值得花时间跑一遍
手里拿到一份名为“315ssm_mysql_jsp 公司员工考勤管理系统”的压缩包时,第一反应可能是:都什么年代了,还在用 SSM + JSP 写考勤?但你打开源码会发现,这类项目恰恰是理解 Java Web 服务端开发最完整的样本。Spring 管对象、SpringMVC 管路由、MyBatis 管数据库,JSP 从服务器渲染页面,整条链路没有任何黑匣子,断点能一路从浏览器打到 MySQL。对于刚入行的开发者,或是需要快速搭一套内部管理系统的中小公司,这个技术组合落地成本极低,部署只需要一个 Tomcat、一个 MySQL 实例,不需要引入微服务体系。
这套系统解决的是行政和人事最头疼的日常事务:员工签到签退、请假审批、加班登记、月末考勤统计。相比拿 Excel 手工登记,它能自动计算迟到早退、按部门汇总出勤率,数据留痕可追溯。适合拿来学习的人,是刚学完 SSM 理论但没写过完整项目的在校生;适合拿来用的人,是公司还没有专用 OA、想先低成本跑起来的小团队。这篇笔记会从环境搭建开始,带你把数据库脚本、后端逻辑、前端页面整条链路跑通,并讲清楚最容易翻车的几个地方。
2. SSM 框架搭建:三个组件怎么分工,配置从哪开始写
2.1 Spring、SpringMVC、MyBatis 在考勤系统里各管什么
SSM 是三个开源框架的缩写组合,在这个考勤系统里分工非常清晰。Spring 是容器,负责创建和管理 Service、Mapper 这些对象,同时承担事务控制;SpringMVC 是 Web 层框架,所有以 .do 或 /api 开头的请求都先进 DispatcherServlet,再由它分发到 Controller 的某个方法;MyBatis 负责数据库访问,把 Java 方法调用映射成 SQL 语句。三层各司其职,所以你把项目拆开看目录结构,一眼就能看出请求从 JSP 页面发出后,经过了 Controller、Service、Mapper,最后落到 MySQL。
选择这套组合而不是 Spring Boot,不是因为它更新,而是因为它更透明。Spring Boot 的自动配置省事,但出了问题不好排查底层逻辑。SSM 的配置全部显式写在 XML 里,数据源连的是哪个库、Mapper 扫描哪个包、视图解析器怎么拼接 JSP 路径,全部一眼可见。对新手来说,这个透明度本身就是最好的学习材料。考勤系统没有高并发、没有分布式事务,SSM 的轻量和可控恰好匹配需求。
2.2 本地跑通的最小配置:pom.xml、web.xml、Spring 配置文件
从压缩包里解压后,先看目录结构。典型布局是 src/main/java 放 Java 源码,src/main/resources 放配置文件,src/main/webapp 放 JSP 页面,sql 目录放数据库脚本,doc 目录放说明文档。我一般会把源码导入 IDE 后,先改三个配置文件,再导入 SQL,最后启动 Tomcat。第一步是确认 pom.xml 里的依赖完整,核心依赖如下:
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.20</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.10</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>这里的关键是 mybatis-spring 这个桥接包,它负责把 MyBatis 的 SqlSessionFactory 交给 Spring 容器管理。没有它,MyBatis 和 Spring 就是两套独立体系,事务和 Mapper 扫描都会失效。版本上 spring-webmvc 和 mybatis-spring 要匹配,如果 Spring 版本太新而桥接包太旧,启动时会直接报 BeanCreationException。
2.3 数据源与 Mapper 扫描:三个必调的参数
配置的核心在 spring-mvc.xml 里,一个文件管三件事:开启注解驱动、配置数据源、配置视图解析器。我习惯把它拆开写,便于排查。最小可运行版本如下:
<context:component-scan base-package="com.company.attendance"/> <mvc:annotation-driven/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/attendance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="123456"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.company.attendance.dao"/> </bean> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" value=".jsp"/> </bean>三个必调参数逐个说。第一个是数据源 url,这里的 serverTimezone=Asia/Shanghai 必须加,MySQL 8.0 驱动默认使用 UTC 时区,不加的话打卡时间和服务器本地时间会差 8 小时,后面所有考勤判定全部错位。第二个是 mapperLocations,配置的是 MyBatis XML 映射文件的路径,路径写错启动不报错,但一调用 Mapper 方法就报 Invalid bound statement。第三个是 basePackage,它告诉 Spring 去哪个包扫描接口,生成代理实现类,这个包名必须和你的 DAO 接口所在包完全一致。视图解析器的 prefix、suffix 决定 Controller 返回的字符串怎么拼接成实际 JSP 路径,如果你把 JSP 放在 webapp 根目录而不是 WEB-INF 下,这里也要对应修改。
3. 数据库设计:考勤系统的表结构为什么这样拆
3.1 员工表、部门表、考勤记录表:核心表关系先理清
打开 sql 目录下的脚本文件,能看到一个完整的考勤数据库设计。设计思路上遵循第三范式,把员工信息和考勤行为分开存,避免冗余。核心是四张表:部门表、员工表、考勤记录表、请假申请表。部门表和员工表是一对多关系,员工表和考勤记录表是一对多关系,员工表和请假申请表也是一对多关系。逻辑上很直观:一个部门有多个员工,一个员工每天有多条考勤记录。
表结构设计直接决定后面统计 SQL 的复杂度。我一般会先检查员工表是否包含部门外键,考勤记录表是否包含员工外键,索引是否建立在关联字段上。一个常见毛病是表建好了但没加索引,数据量到几万条后,月底统计报表直接卡死。建表脚本核心部分如下:
CREATE TABLE `dept` ( `id` INT NOT NULL AUTO_INCREMENT, `dept_name` VARCHAR(50) NOT NULL COMMENT '部门名称', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'; CREATE TABLE `employee` ( `id` INT NOT NULL AUTO_INCREMENT, `dept_id` INT NOT NULL COMMENT '所属部门ID', `emp_no` VARCHAR(20) NOT NULL COMMENT '工号', `name` VARCHAR(30) NOT NULL COMMENT '姓名', `password` VARCHAR(64) NOT NULL COMMENT '登录密码', `position` VARCHAR(50) DEFAULT NULL COMMENT '岗位', PRIMARY KEY (`id`), UNIQUE KEY `uk_emp_no` (`emp_no`), KEY `idx_dept_id` (`dept_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表';注意几个细节。emp_no 工号加了唯一索引,这是员工登录的账号,不允许重复;dept_id 加普通索引,因为按部门统计是高频查询;密码字段长度设为 64,是为了兼容 MD5 或 SHA-256 摘要结果,如果只设 32 位,后续想升级加密算法就麻烦了。字符集用 utf8mb4 而不是 utf8,因为 utf8 在 MySQL 里存不了生僻字和部分表情符号,员工姓名场景虽然概率低,但一次乱码就够折腾了。
3.2 打卡状态位:一天的考勤记录怎么存储
考勤记录表是整个系统的核心,设计它的时候要回答一个问题:一个员工一天需要几条记录。常见方案是两条,一条上班打卡、一条下班打卡,用 type 字段区分。但也有一行搞定的方案,就是下面这种设计:每条记录对应员工某一天,签退时间允许为空,下班前再更新同一行。两种方案各有取舍,两行方案更灵活,可以支持一天多次打卡的班次;单行方案统计更方便,一条记录就是一个员工一天的完整考勤状态。
CREATE TABLE `attendance` ( `id` INT NOT NULL AUTO_INCREMENT, `emp_id` INT NOT NULL COMMENT '员工ID', `work_date` DATE NOT NULL COMMENT '上班日期', `clock_in_time` DATETIME DEFAULT NULL COMMENT '上班打卡时间', `clock_out_time` DATETIME DEFAULT NULL COMMENT '下班打卡时间', `status` TINYINT DEFAULT 0 COMMENT '0正常 1迟到 2早退 3迟到+早退 4缺卡', PRIMARY KEY (`id`), UNIQUE KEY `uk_emp_date` (`emp_id`, `work_date`), KEY `idx_work_date` (`work_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤记录表';这里的关键设计是唯一索引 uk_emp_date,它保证同一员工同一天只有一条考勤记录。第一次打卡执行 INSERT,第二次打卡执行 UPDATE,不会出现重复记录。status 字段是冗余设计,它不靠数据库计算,而是由后端在打卡时判断后写入,虽然多占一个字节,但月末统计时直接 WHERE status > 0 就能找出所有异常考勤,不用每次现算。这个取舍对考勤这种高频写、低频复杂查的场景非常划算。
3.3 SQL 脚本导入:命令行三步走
拿到 sql 文件后,我习惯直接用命令行导入,不通过 IDE 的可视化工具,因为脚本里可能包含存储过程或事务语句,某些图形化工具会半路报错。导入前先确认 MySQL 服务已启动,然后依次执行建库、选库、导入三步。
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS attendance DEFAULT CHARSET utf8mb4;" mysql -u root -p attendance < attendance.sql mysql -u root -p -e "USE attendance; SHOW TABLES;"第一条命令创建数据库,指定 utf8mb4 字符集,避免脚本里建表语句没写 DEFAULT CHARSET 导致乱码;第二条命令把脚本里的建表和初始化数据全部导入;第三条命令验证是否出现 dept、employee 等表。如果导入时报 Unknown database 错误,说明第一条命令执行失败,检查 root 密码和 MySQL 服务状态。如果报语法错误,用记事本打开 sql 文件检查文件编码是不是 UTF-8,从 Windows 下拷贝的脚本经常是 GBK 编码,在 Linux 上执行就会乱码报错。
4. 核心业务实现:签到、签退、请假审批的代码调用链
4.1 登录后签到:Controller 到 Service 到 Mapper 的完整流程
考勤系统最核心的操作就是签到。用户登录后进入首页,点击签到按钮,前端发起请求到后端,后端先查今天有没有记录,没有就插入,有就更新签退时间。下面是 Controller 层的签到接口,它接收当前登录员工的 ID,调用 Service 完成打卡逻辑:
@Controller @RequestMapping("/attendance") public class AttendanceController { @Autowired private AttendanceService attendanceService; @RequestMapping("/sign") @ResponseBody public Map<String, Object> sign(HttpSession session) { Employee emp = (Employee) session.getAttribute("loginUser"); Map<String, Object> result = new HashMap<>(); if (emp == null) { result.put("code", 401); result.put("msg", "未登录"); return result; } try { attendanceService.sign(emp.getId()); result.put("code", 200); result.put("msg", "打卡成功"); } catch (Exception e) { result.put("code", 500); result.put("msg", "打卡失败:" + e.getMessage()); } return result; } }这段代码里有两个容易被忽略的点。第一个是登录用户从 session 里取,而不是从表单里再传一次,防止伪造请求替别人打卡。第二个是返回类型用 Map 配合 @ResponseBody,这比返回 ModelAndView 更简洁,前端拿到 JSON 后自行提示成功或失败。实际项目里我给 session 里的用户对象加了个超时时间,30 分钟无操作自动失效,考勤这种敏感操作必须保证会话有效。
4.2 迟到早退判定:后端计算的边界条件
签到签退的核心在 Service 层,这里要做两件事:判断是签到还是签退,计算是否迟到早退。常见做法是在 application.properties 或 XML 里配置上班时间和下班时间,Java 代码按时间字符串比较。这里有个容易踩坑的点:不要用 String 的 compareTo 直接比较时间,要用 LocalTime 解析后再比,否则格式不统一会出现 8:5 这种脏数据导致比较错误。
public void sign(Integer empId) { LocalDate today = LocalDate.now(); Attendance record = attendanceDao.findByEmpIdAndDate(empId, today); LocalTime now = LocalTime.now(); LocalTime workStart = LocalTime.parse("09:00:00"); LocalTime workEnd = LocalTime.parse("18:00:00"); if (record == null) { record = new Attendance(); record.setEmpId(empId); record.setWorkDate(today); record.setClockInTime(LocalDateTime.now()); int status = now.isAfter(workStart) ? 1 : 0; record.setStatus(status); attendanceDao.insert(record); } else { record.setClockOutTime(LocalDateTime.now()); if (now.isBefore(workEnd)) { record.setStatus(record.getStatus() + 2); } attendanceDao.update(record); } }边界条件要单独说。9:00:00 整打卡算不算迟到,各公司定义不同,如果要求 9 点前必须到,用 isAfter 就是迟到;如果 9 点整还可以,要用 isBefore 配合等号判断。同样,18:00:00 整打卡算准时还是早退,要按公司制度来。我把这两个判断抽成了独立方法,放到 config 里方便调整。还有跨天情况,如果下班时间是凌晨 2 点,LocalTime.parse 和 LocalDate.now 组合就会出问题,需要引入班次表概念,但基础版考勤系统很少涉及。
4.3 请假审批与月度统计:SQL 聚合怎么写
请假模块相对独立,核心是审批流。员工提交请假申请,上级登录后看到待审批列表,点击通过或驳回。数据上只需要在请假表里加一个 status 字段,0 待审批、1 已通过、2 已驳回。审批通过后,考勤统计时要把请假天数从应出勤天数里扣除。这里要注意事务,审批操作和扣减考勤状态要放在同一个事务里,否则会出现审批通过了但统计还显示缺勤的数据问题。
SELECT e.emp_no, e.name, d.dept_name, COUNT(DISTINCT a.work_date) AS actual_days, SUM(CASE WHEN a.status IN (1, 3) THEN 1 ELSE 0 END) AS late_days, SUM(CASE WHEN a.status IN (2, 3) THEN 1 ELSE 0 END) AS leave_early_days, SUM(CASE WHEN a.status = 4 THEN 1 ELSE 0 END) AS miss_days FROM employee e LEFT JOIN dept d ON e.dept_id = d.id LEFT JOIN attendance a ON e.id = a.emp_id WHERE a.work_date BETWEEN #{startDate} AND #{endDate} GROUP BY e.id, e.emp_no, e.name, d.dept_name;这条 SQL 是月末统计的主力。使用 LEFT JOIN 而不是 INNER JOIN,是为了把没打卡的员工也统计出来,否则没考勤记录的人直接消失。SUM(CASE WHEN ...) 是条件聚合的标准写法,比多次查询再在 Java 里拼装高效得多。GROUP BY 后面跟着的字段要和 SELECT 里非聚合字段严格一致,MySQL 5.7 之后默认开启了 ONLY_FULL_GROUP_BY,不一致直接报语法错误。实际跑这条 SQL 之前,我一般会在 MySQL 客户端先执行一遍 EXPLAIN,看看有没有走索引,如果 type 列出现 ALL 就说明全表扫描了,数据量大时要优化索引。
5. 避坑排查:SSM 考勤系统最常见的五个翻车现场
5.1 现象:本地启动 Tomcat 后访问页面 404
原因可能有两个方向。一个是项目没有正确部署到 webapps 目录,IDE 里显示启动了但访问路径不对;另一个是 SpringMVC 的前端控制器 url-pattern 配置把 JSP 请求也拦截了,导致视图解析器找不到页面。排查时先看 Tomcat 控制台日志里项目是否 successful started,再在浏览器地址栏试访问 /项目名/ 根路径。我遇到最多的场景是 web.xml 里 DispatcherServlet 的 url-pattern 配置成了 /*,这会把所有请求包括 JSP 都拦下来,正确做法是配成 *.do 或 /。
5.2 现象:数据库里记录的时间和本地时间差 8 小时
这个基本可以断定是 JDBC 连接串的时区问题。MySQL 8.0 驱动默认连接时区是 UTC,如果代码里用 LocalDateTime.now() 写入,时间会直接取本机时间,但查询时驱动又按 UTC 转换,一进一出就差了 8 小时。解决方式就是在数据源 url 里显式声明 serverTimezone=Asia/Shanghai。注意这个参数在连接串中要放在末尾,前面用 & 连接多个参数,XML 里 & 符号要转义成 &,忘了转义 Spring 解析 XML 时会直接报错。
5.3 现象:Mapper 接口方法调用报 BindingException,说 Invalid bound statement
原因是 MyBatis 找不到对应的 XML 映射文件。常见情况有三种:XML 文件没有放在 mapperLocations 指定的目录下;XML 文件里的 namespace 写错了包名;DAO 接口方法名和 XML 里语句的 id 不一致。排查方法很直接,打开编译后的 target/classes 目录,看 mapper 目录下有没有对应的 XML 文件,没有就说明资源没有打进 classes,需要在 pom.xml 里配置资源过滤。
5.4 现象:打卡成功后数据库里没有记录,但页面提示成功
这是典型的 Spring 事务没有生效。SSM 整合时需要在 spring 配置文件里配置事务管理器 DataSourceTransactionManager,并在 Service 实现类上标注 @Transactional。如果这两个条件不满足,Service 方法查完数据库会自动提交,但一旦后续有异常回滚,数据就丢了,而 Controller 层被 try-catch 捕获后依然返回成功提示。排查时在 Service 方法里加一个日志输出,查看有没有进入事务代理,最简单的方法是确认配置文件中有 txManager 这个 bean 且注解驱动开启。
5.5 现象:修改 JSP 页面后刷新不生效,还是旧页面
Tomcat 对 JSP 有缓存机制,开发时修改页面后需要清理 work 目录下的缓存。有些 IDE 的 Tomcat 插件是热部署模式,但 JSP 缓存依然存在。解决方式是停掉 Tomcat,删除 CATALINA_HOME/work 目录下的内容再重启。另外如果使用 IDEA 的 Tomcat 集成部署,确认 Deployment 里是 war exploded 模式,而不是 war 包模式,后者每次改 JSP 都要重新打包。
6. 进阶路线:跑通之后怎么改出更适合生产的样子
6.1 从 JSP 到前后端分离:给考勤系统留一条升级路
基础版考勤系统用的是 JSP 服务端渲染,页面嵌在 Java 代码里,前端改动要重启 Tomcat,不灵活但胜在简单。如果想升级成前后端分离,不需要推倒重来。Controller 层本来就是返回 JSON,只需要把原来返回 ModelAndView 的方法全部改成 @ResponseBody,再把 JSP 页面替换成静态 HTML 加 Vue 或原生 JS。视图解析器可以保留,但原来的 /WEB-INF/jsp/ 路径已经用不上。我做过一次这样的迁移,两天时间就把登录、打卡、统计三个核心页面全部切换完成,效果立竿见影,页面交互响应更快,前端也终于可以用现代构建工具调试。
6.2 给考勤记录加一层 Redis 缓存
如果考勤系统承担整个公司几百人的打卡压力,月底统计频繁查询数据库会吃力。我一般会在统计报表接口前加一层 Redis 缓存,key 用统计参数的 MD5 值,value 存统计结果 JSON,设置 5 分钟过期。打卡后主动删除对应缓存,这样同一天的统计结果不会出现脏数据。引入 Redis 后用 Spring Data Redis 模板操作,但要注意序列化方式,默认 JDK 序列化在 Redis 客户端里看到的是乱码,配置时用 GenericJackson2JsonRedisSerializer 替换。这一步做完,统计接口的响应时间通常能从几百毫秒降到几十毫秒。
6.3 跑通后的自测清单与交接文档
我把这个系统交付给别人之前,一般会跑一遍自己写的自测清单:新建一个部门、新建员工、用该员工登录、签到、签退、提交请假申请、用管理员账号审批、查看月度统计报表。整个流程走完后确认数据写入正确,再检查异常分支:重复打卡会不会更新同一行,未审批的请假会不会计入缺勤,删除部门时关联员工如何处理。确认无误后,把配置文件里的数据库密码改掉,把初始化账号信息写进交付文档。
想起一次交付经历,某公司拿去用了两周,月底算考勤时发现统计数据和手工台账对不上,排查后定位是员工跨天加班到凌晨 2 点,打卡时间记到了第二天,被算成了缺卡。后来我在打卡逻辑里加了一个规则:22:00 之后的打卡自动归属到前一天的工作日记录,才算把这个问题解决。这类业务规则是需求文档里永远不会写清楚的,只能在实际使用中不断补漏。考勤系统看起来简单,真正跑到业务里才知道边界条件有多磨人,希望这篇笔记能帮你少走一段弯路。
本文还有配套的精品资源,点击获取