简介:基于SSM框架(Spring、SpringMVC、MyBatis)与JSP技术实现的企业人事管理系统,是一份面向毕业设计、课程项目及企业人事管理初学者的完整开发资料。系统围绕员工信息、薪酬福利、考勤记录、绩效考核等核心业务,采用MVC分层架构,囊括数据库设计SQL脚本、前后端功能模块与详细开发文档。资源包共987个文件,以Java源码、JSP页面、CSS样式、JS脚本、PNG/GIF图片、SQL脚本等为主,整体体积11.48MB,便于下载与查阅。当前已有58人学习。读者可掌握系统架构搭建、数据库表设计、MyBatis持久层操作、SpringMVC请求处理及JSP动态页面渲染等关键技术,附带说明文档与欢迎文档,能有效辅助部署运行与二次开发,整体内容完整、结构清晰,适合快速入门企业级Web项目实践。
1. 为什么 SSM + JSP 在人事系统里依然是不错的选择
最近在接手一个内部人事管理系统时,发现很多团队还在纠结要不要把老系统从 SSM+JSP 重构成 Spring Boot 前后端分离。实际上,对于员工信息录入、薪酬核算、考勤统计这类以表单提交和报表导出为主的业务,SSM+JSP 这套组合在中小型企业里依然能打。它的学习曲线平缓,开发人员容易找,而且 JSP 服务端渲染对 IE 兼容性好,打印页面也方便。这套资源里除了源码,还带 db.sql 初始化脚本和说明文档,适合做毕设、公司内部小系统,也适合想快速搭建一个带权限管理的人事后台的开发者。下面我会从数据模型设计、SSM 请求流转、核心业务模块三个层面拆解实现思路,最后给出部署和排错经验。
2. 数据模型先行:人事系统的表结构设计与 SSM 职责分配
2.1 为什么先定表结构再写代码
人事系统最核心的是数据关系。员工属于部门,部门有多级;员工有薪酬记录、考勤记录、绩效考核记录。如果一上来就写 Controller,后面改表结构会非常痛苦。我的做法是先把数据库脚本跑通,再根据表结构反推 Java 实体类。这套项目的 db.sql 里已经设计好了六张主表:employee、department、salary、attendance、performance、user,其中 user 表用于登录。下面给出核心建表语句,数据库用 MySQL 5.7。
CREATE TABLE `department` ( `dept_id` int(11) NOT NULL AUTO_INCREMENT, `dept_name` varchar(50) NOT NULL, `parent_id` int(11) DEFAULT '0', `manager_name` varchar(20) DEFAULT NULL, PRIMARY KEY (`dept_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4; CREATE TABLE `employee` ( `emp_id` int(11) NOT NULL AUTO_INCREMENT, `emp_no` varchar(20) NOT NULL, `emp_name` varchar(20) NOT NULL, `gender` char(1) DEFAULT 'M', `dept_id` int(11) NOT NULL, `position` varchar(30) DEFAULT NULL, `hire_date` date DEFAULT NULL, `phone` varchar(15) DEFAULT NULL, `status` tinyint(4) DEFAULT '1', PRIMARY KEY (`emp_id`), UNIQUE KEY `uk_emp_no` (`emp_no`), KEY `idx_dept` (`dept_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里的关键是 emp_no 设计成唯一索引,工号是人事系统里的自然主键,后续考勤、薪酬都通过 emp_no 关联。dept_id 建立普通索引,因为员工列表最常见的查询就是按部门过滤。status 字段代表在职状态,1 在职,0 离职,这是人事系统的通用做法。
2.2 SSM 三个框架在这里各自管什么
Spring 在系统里扮演的是容器和事务管理器。我的经验是:业务层的@Service类里凡是涉及多表更新的方法,必须在方法上加@Transactional,尤其是员工入职时要同时插入 employee 表和初始化一条 salary 记录,不加事务会导致数据不一致。
SpringMVC 的分工是接收前端请求,做参数绑定和校验。这里有个细节:JSP 表单里提交日期字段时,Controller 里必须配合@DateTimeFormat(pattern="yyyy-MM-dd"),否则会报 400 错误。在人事管理系统的员工新增页面上,日期格式是最容易踩的坑。
@Controller @RequestMapping("/employee") public class EmployeeController { @Autowired private EmployeeService employeeService; @RequestMapping("/save") public String save(@Valid Employee employee, BindingResult br) { if (br.hasErrors()) { return "employee/edit"; } employeeService.save(employee); return "redirect:/employee/list"; } }这段代码里没有用 SpringMVC 简单数据绑定的默认格式,因为@Valid配合实体类上的@NotNull、@Pattern注解,才能把表单校验从 Service 层里剥离出来。这里注意 BindingResult 必须紧跟被校验对象,否则验证失败时 Spring 不会走错误页面,而是直接抛异常。
2.3 MyBatis 在这里的价值是动态 SQL
考勤、薪酬模块的查询条件非常多:时间区间、部门、员工姓名、状态。用 JDBC 拼接 SQL 容易出注入漏洞,用 JPA 又难写复杂统计。MyBatis 的<where>和<foreach>标签在这种场景下最顺手。比如批量查询员工薪酬记录:
<select id="selectSalaryByCondition" resultType="map"> SELECT e.emp_no, e.emp_name, d.dept_name, s.base_salary, s.bonus, s.deduct, s.salary_date FROM salary s LEFT JOIN employee e ON s.emp_no = e.emp_no LEFT JOIN department d ON e.dept_id = d.dept_id <where> <if test="empNo != null and empNo != ''"> AND s.emp_no = #{empNo} </if> <if test="deptId != null"> AND e.dept_id = #{deptId} </if> <if test="startDate != null"> AND s.salary_date >= #{startDate} </if> </where> ORDER BY s.salary_date DESC </select><where>标签会自动去掉第一个条件的 AND,这是 MyBatis 最实用的一个特性。注意>符号在 XML 里要转义成>,否则解析报错。这条 SQL 用了 LEFT JOIN 而非 INNER JOIN,因为离职员工的部门可能被删,但薪酬记录要保留。
2.4 表之间的关联设计
人事系统里最常见的扩展操作是「部门合并」或者「员工调岗」。如果直接在 employee 表里加一个 dept_id,调岗时直接 update 即可,但历史薪酬记录会跟着变。这里我建议把部门快照冗余一份到 salary 表,比如加一列dept_name_at_month,这样年终统计各部门人力成本时,按照当月的部门归属算,而不是按现在的部门算。这个设计并不复杂,但很多初级开发会忽略,导致后来的报表数据对不上。
3. 从前端 JSP 到 MySQL:登录、权限与员工信息管理实现
3.1 登录模块与权限控制的完整请求链路
人事系统第一个要解决的是安全登录。这里用最经典的 Session + 拦截器方案。用户输入用户名密码后,表单提交到 LoginController,经过 MD5 加盐比对后,把用户信息放入 Session。之后所有非登录请求经过 SpringMVC 拦截器校验 Session 是否有效。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User user = (User) session.getAttribute("loginUser"); if (user == null) { String url = request.getContextPath() + "/login.html"; response.sendRedirect(url); return false; } return true; } }这个拦截器里没有做角色判断,只是因为人事系统里权限粒度是「功能权限」而非「角色权限」。如果不同角色能访问的页面不同,我一般会在门面菜单表里配置 url 列表,然后在拦截器里查一遍当前用户是否有该 url 的访问权。注意:第一次登出后浏览器后退,页面能缓存内容,但点击任何功能会跳回登录页,这是正常的,不用刻意加 no-cache。
3.2 员工信息管理:一个完整的新增流程代码
员工新增是人事系统里最典型的 CRUD 场景。页面用 JSP 的form:form标签绑定实体,提交到/employee/save,Controller 接收后调用 Service,Service 里先检查工号唯一性,再插入员工表,同时插入一条初始薪酬记录。
@Service @Transactional public class EmployeeServiceImpl implements EmployeeService { @Autowired private EmployeeMapper employeeMapper; @Autowired private SalaryMapper salaryMapper; @Override public void save(Employee employee) { int count = employeeMapper.selectCountByEmpNo(employee.getEmpNo()); if (count > 0) { throw new RuntimeException("工号已存在"); } employee.setStatus(1); employeeMapper.insert(employee); Salary s = new Salary(); s.setEmpNo(employee.getEmpNo()); s.setBaseSalary(0.0); s.setSalaryDate(new Date()); salaryMapper.insert(s); } }这个方法上的@Transactional是必须的。因为 insert employee 与 insert salary 是两个操作,如果第二个失败,第一个已经提交,会造成员工信息存在但薪酬记录缺失。Spring 的默认事务回滚条件是 RuntimeException,所以代码里主动抛了 RuntimeException 来触发回滚。这里我没有做 try-catch 吞异常,因为一旦 catch 掉,事务就失效了。
3.3 列表页的动态查询与分页
员工列表页要用到多条件组合查询,JSP 页面顶部放一个搜索表单,表单的 value 绑定到搜索条件。后端接收的条件对象用一个查询专用 DTOEmployeeQuery来接收,而不是直接用 Employee 实体,因为实体里没有pageNum、pageSize这些字段。
@RequestMapping("/list") public String list(EmployeeQuery query, Model model) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); List<Map<String, Object>> list = employeeMapper.selectEmployeeList(query); PageInfo<Map<String, Object>> pageInfo = new PageInfo<>(list); model.addAttribute("pageInfo", pageInfo); return "employee/list"; }PageHelper 是集成 MyBatis 的一件利器,原理是使用 MyBatis 拦截器,在 SQL 语句执行前自动拼接 COUNT 查询和 LIMIT 子句。用它的前提是必须保证 mapper 查询语句是从com.github.pagehelper.PageHelper.startPage跟后的那条语句开始生效。在写代码时要注意,startPage 与 select 之间不能有别的查询,否则分页会作用到错误的 SQL 上。
3.4 页面上信息提示与表单回显
JSP 页面上回显选中的部门,需要 Controller 把部门列表传过去,然后使用 JSTL 的c:forEach和c:if判断选中项。这里有个容易犯的错:新增页面和编辑页面共用一个 JSP 时,编辑页面要回显数据,但新增页面里实体为空,直接访问employee.deptId会抛异常。解决方式是在 Controller 的 add 方法里先model.addAttribute("employee", new Employee())。
<select name="deptId"> <c:forEach items="${deptList}" var="dept"> <option value="${dept.deptId}" <c:if test="${employee.deptId == dept.deptId}">selected</c:if>> ${dept.deptName} </option> </c:forEach> </select>这里的比较用的是==,但 employee.deptId 是 Integer 类型,JSTL 比较时要注意:如果两个都是 Integer,==可以正常用;如果一个是 String 一个是 Integer,必须用${employee.deptId eq dept.deptId},否则恒为 false。
4. 薪酬、考勤与绩效:让业务规则落到 SQL 和 Java 层面
4.1 薪酬模块的批量计算逻辑
薪酬模块最核心的操作是「算工资」。真实的企业里,工资等于基本工资加绩效奖金减考勤扣款减社保公积金。这套系统里的 salary 表有 base_salary、bonus、deduct 三个字段,salary_date 记录月份。每月发薪时,HR 选择月份,系统根据考勤汇总结果自动计算出 deduct 字段。计算逻辑放在 Service 层:
public void computeMonthSalary(String salaryMonth) { List<AttendanceSummary> summaryList = attendanceMapper.summaryByMonth(salaryMonth); for (AttendanceSummary s : summaryList) { Salary salary = new Salary(); salary.setEmpNo(s.getEmpNo()); salary.setSalaryDate(DateUtil.parseMonth(salaryMonth)); double deduct = s.getLateCount() * 50 + s.getAbsentDays() * 200; salary.setDeduct(deduct); salary.setBonus(0.0); salaryMapper.insertOrUpdate(salary); } }这个逻辑简化了真实情况,但思路是对的:先从考勤表里聚合出每个员工的迟到次数和缺勤天数,再按照公司标准计算出扣款。这里设计了一个insertOrUpdate的 mapper 方法,实际 SQL 是 MySQL 的INSERT INTO ... ON DUPLICATE KEY UPDATE,这样重复计算一个月工资时会更新而不是插入新记录,避免产生垃圾数据。
4.2 考勤统计的 MyBatis 分组查询
考勤表设计的核心是每条记录代表一次签到或签退,字段包括 emp_no、att_date、att_time、type(1 上班,2 下班)。统计某月每个人迟到次数,需要比较每条记录的时间与公司规定的上班时间。在 SQL 里最适合用CASE WHEN判断:
<select id="summaryByMonth" resultType="map"> SELECT emp_no, SUM(CASE WHEN type = 1 AND att_time > '09:05:00' THEN 1 ELSE 0 END) AS lateCount, COUNT(DISTINCT CASE WHEN type = 1 AND att_time IS NULL THEN att_date END) AS absentDays FROM attendance WHERE DATE_FORMAT(att_date, '%Y-%m') = #{month} GROUP BY emp_no </select>这里att_time是 TIME 类型,直接与字符串比较没问题。absentDays的统计思路是:如果在某天没有 type=1 的记录,就视为缺勤。但这样统计不了整天没记录的人,因为COUNT(DISTINCT att_date)里没有当天记录就不会有行。正确的做法是需要一张日历表与考勤表左连接。比较简单的替代方案是先把员工表与考勤表按天条件左连接。
4.3 绩效模块的评分与等级映射
绩效表 performance 通常包含四个字段:emp_no、assess_date、score、grade。grade 是根据 score 自动算出来的。我一般把评分规则放在一个独立的方法里,方便规则变化时只改一处。
public String calcGrade(Double score) { if (score == null || score < 60) return "D"; if (score < 75) return "C"; if (score < 90) return "B"; return "A"; }这个逻辑被多个地方复用:绩效录入页面提交时调用、批量评分导入时也调用。注意边界条件单测要覆盖 60、75、90 这三个临界值,因为<=和<差一位最后结果就不同。如果在导入 Excel 时发现 score 字段是字符串数字,还要先转换再比较。
4.4 用事务保证薪酬和考勤数据一致
薪酬计算依赖考勤汇总数据,所以计算过程中考勤数据不允许被修改。最简单的做法是设置一个计算状态字段,在考勤编辑页面里加一个锁标识。每当进入薪酬计算时,先更新考勤的锁状态为「计算中」,计算完成后置为「已锁定」。这样做的好处是避免计算过程中 HR 又去修改某天的考勤,导致金钱对不上。
public void lockAttendance(String month) { attendanceMapper.lockByMonth(month); // UPDATE attendance SET locked = 1 WHERE month = #{month} }注意,如果这个系统是多人同时操作的,锁状态可能被并发覆盖,一个线程把其它线程刚计算的数据解锁。此时可以用乐观锁版本号,但我建议在没有任何分库分表的场景下,直接在 lockByMonth 语句里加WHERE locked = 0,让后执行的覆盖先执行的,并在页面提示用户「当前月份已锁定,如需修改请联系管理员」。
5. 部署到 Tomcat:配置、启动与 JSP 编译问题排查
5.1 数据源配置与项目目录结构
拿到这套资源后,第一步是修改jdbc.properties里的数据库连接信息。注意 MySQL 8.x 与 5.x 的驱动类不一样,MySQL 8 驱动类是com.mysql.cj.jdbc.Driver,同时要加上 URL 参数useSSL=false&serverTimezone=Asia/Shanghai,否则会报时区错误。
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/hrms?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456配置完数据库,先把 db.sql 导入再启动,我一般用 IDEA 的数据源面板执行。这里要注意 db.sql 文件编码,如果控制台执行报中文乱码,用mysql --default-character-set=utf8mb4 -uroot -p hrms < db.sql强制指定编码。
5.2 配置 Tomcat 把项目跑起来
这个项目基于 Maven 构建,如果 pom.xml 里没有依赖,可以直接编译。Tomcat 7 及以上版本对 JSP 的 EL 表达式支持较完整,推荐用 Tomcat 8.5。在 IDEA 里打开 Project Structure,把项目打包成 war 包,放到 Tomcat webapps 目录。或者直接在 IDEA 中配置 Tomcat 的 Deployment,添加 Artifact。
<!-- 建议在 pom.xml 里保证以下依赖存在,否则启动会报 500 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>taglibs</groupId> <artifactId>standard</artifactId> <version>1.1.2</version> </dependency>没有 JSTL 依赖时,JSP 页面里的<c:forEach>标签会全部报错,表现是页面空白但日志里出现 Unable to find tag library。这个坑出现的频率极高,尤其是在手工添加 jar 包时最容易漏。
5.3 页面缓存与 JSP 修改后不生效
开发阶段经常会遇到修改了 JSP 代码,刷新页面还是旧内容。这是 JSP 被 Tomcat 编译成 class 文件后缓存导致的。默认情况下 Tomcat 会检测文件修改时间,如果文件时间戳未变,就不会重新编译。解决方法是修改 web.xml 或者在 JSP 顶部加一行调试指令,但我建议直接清理 Tomcat 的 work 目录。
rm -rf apache-tomcat-8.5.85/work/Catalina/localhost/上面这行命令删除了项目下所有 JSP 编译后的 java 和 class 文件。Tomcat 在运行时遇到请求会自动重新编译,等待时间一般不超过 3 秒。这也是排查 JSP 编译问题的起点:网上很多人搜「jsp编译class文件保存在哪里」,答案就在 Tomcat 的 work/Catalina/localhost/ 目录下,每个应用一个文件夹,里面能找到对应 JSP 生成的 Java 源码,报错时先看这个文件。
5.4 屏蔽 JSP 离开页面提示的小技巧
人事系统里经常会有编辑表单后直接点导航菜单跳走的情况,浏览器默认会弹一个「确认离开」的原生提示。如果想让这个提示格式化,JSP 页面一般配合beforeunload事件,但系统需求是「屏蔽」这个默认提示,即用户离开时直接走,不弹窗。遇到的场景是用户已经点了一次提交,表单数据保存后再次刷新页面,浏览器会重复弹出离开确认。这个时候可以在提交成功后的回调里移除 beforeunload 监听器:
window.onbeforeunload = function(e) { if (saved) { return undefined; } e = e || window.event; if (e) { e.returnValue = '关闭提示'; } return '关闭提示'; };注意这里不能直接返回false,因为现代浏览器为了防钓鱼,要求返回值必须是字符串才能触发提示。如果保存后要彻底禁用,把函数置空:
saved = true; // 提交成功后置位 window.onbeforeunload = null;实际开发中这个坑的出现频率取决于项目是否用了前端表单验证框架。如果提交按钮没有绑定onsubmit事件,而是直接type="button"调用 JS,就必须手动操作上面的监听器。
5.5 数据备份与恢复:HR 下限时最该做的事
系统上线后的日常运维里,人事数据是最怕丢的。这里给一个最简单可靠的备份方案,用 Linux 定时任务执行 mysqldump,保留最近 15 天备份。注意 mysqldump 要放在 crontab 里,并且导出时要用--single-transaction来避免锁表,否则白天执行会导致业务卡顿。
0 2 * * * mysqldump -uroot -p'密码' --single-transaction --default-character-set=utf8mb4 hrms > /backup/hrms_$(date +\%Y\%m\%d).sql恢复数据时不要直接覆盖正在运行的库,先恢复到新库验证,再切换到新库。恢复命令是mysql -uroot -p hrms < hrms_20250101.sql。这套系统的说明文档里也写了定期备份建议,但没有给具体命令,以上是我自己常用的做法。最后提醒一下,处理人事数据时,测试和演示环境不要用真实员工信息,这个习惯比任何技术方案都重要。
本文还有配套的精品资源,点击获取