简介这是一套基于SSMSpringSpringMVCMyBatis架构的员工管理系统完整项目面向JavaWeb初学者、课程设计或毕业设计开发者帮助解决企业员工信息管理信息化、规范化的实际需求。系统采用JSPJavaScriptJQuery作为前端语言MySQL作为数据库开发环境为IDEA2019搭配Tomcat服务器涵盖员工管理、薪酬管理、用户管理、通知管理、文件管理等核心模块并区分超级管理员、普通管理员、临时管理员三种角色权限。资源包共447个文件包含44个java源码、49个jsp页面、53个xml配置、28个css样式、40张jpg图片及1个sql脚本等压缩包约93.17MB另附配套文档便于理解项目结构。目前已有413人学习下载读者可获取完整前后端源码、数据库脚本与说明文档快速掌握SSM框架整合与MVC分层开发思路适合作为实战练手或二次开发的基础模板。1. 从一张 Excel 台账说起SSM 员工管理系统到底解决什么问题很多中小团队管人起点都是一张共享 Excel入职加一行离职删一行调薪改个数字。三个人以内还能凑合一旦过十人问题就来了——谁改的、什么时候改的、历史记录在哪全靠翻聊天记录。基于 SSM 架构的员工管理系统本质就是把这张 Excel 换成一套有权限、有审计、有校验的 Web 应用。SSM 是 Spring SpringMVC MyBatis 的组合国内大量企业后台至今仍在用它原因很实在分层清晰、SQL 可控、上手成本低招人也好招。这套系统通常包含员工档案、部门岗位、考勤薪资、账号权限几大块配套文档和 SQL 脚本让整套东西能直接跑起来。适合谁适合要交课程设计的学生、要给内部搭轻量 HR 工具的后端、以及想拿一个完整 CRUD 项目练分层思想的人。它不炫技但每一层都能讲清楚为什么这么分。2. 三层怎么切SSM 员工管理系统的分层设计与建表思路2.1 为什么是 Controller-Service-Mapper 而不是全塞一起刚接触 SSM 的人最容易犯的错是把业务逻辑写进 ControllerSQL 又散落在 Service 里最后改一个字段要翻五个文件。正确的切法是按职责分三层Controller 只负责接参数、做基础校验、返回统一结果Service 承载业务规则比如「离职员工不能再分配新部门」「工号全局唯一」Mapper 只管和数据库打交道一个方法对应一条 SQL。这样切的好处是业务规则变了只动 Service表结构变了只动 Mapper 和实体类互不牵连。分层不是形式主义它直接决定了你后期加需求时的痛苦程度。举个真实场景某公司要在员工列表加一个「按入职年限筛选」如果当初 SQL 写在 Controller 里你得在控制层拼字符串而分层之后只需要在 Mapper 加一个带条件的查询方法Service 透传Controller 加个参数即可。这就是分层的价值——把变化隔离在最小范围。2.2 员工管理系统的核心表结构与字段约束一套能用的员工管理系统最少需要五张表部门表、岗位表、员工表、用户表、操作日志表。下面给出核心建表语句字段命名用下划线风格和 MyBatis 的驼峰映射配合。-- 部门表支持上下级用 parent_id 自关联 CREATE TABLE sys_dept ( dept_id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(50) NOT NULL COMMENT 部门名称, parent_id BIGINT DEFAULT 0 COMMENT 上级部门ID0为顶级, order_num INT DEFAULT 0 COMMENT 排序, status TINYINT DEFAULT 1 COMMENT 1正常 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 员工表工号唯一部门岗位外键关联 CREATE TABLE emp_employee ( emp_id BIGINT PRIMARY KEY AUTO_INCREMENT, emp_no VARCHAR(20) NOT NULL COMMENT 工号全局唯一, emp_name VARCHAR(30) NOT NULL COMMENT 姓名, gender TINYINT DEFAULT 0 COMMENT 0未知 1男 2女, phone VARCHAR(20) COMMENT 手机号, dept_id BIGINT COMMENT 所属部门, post_id BIGINT COMMENT 岗位, hire_date DATE COMMENT 入职日期, status TINYINT DEFAULT 1 COMMENT 1在职 0离职, del_flag TINYINT DEFAULT 0 COMMENT 逻辑删除标记, UNIQUE KEY uk_emp_no (emp_no), KEY idx_dept (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个设计决策值得说清楚。第一员工表用del_flag做逻辑删除而不是物理删除因为员工离职后历史考勤、薪资记录还要关联物理删了外键就断了。第二emp_no加了唯一索引把「工号不重复」这条业务规则下沉到数据库即使 Service 层校验被绕过数据库也会兜底报错。索引方面dept_id建普通索引因为按部门查人是最频繁的查询。2.3 用 MyBatis 映射员工列表的分页查询列表页是使用频率最高的功能分页查询写不好数据量一上来就卡。下面是一个带部门名称联查的分页 Mapper 方法。!-- EmpMapper.xml联查部门名称支持按姓名和状态筛选 -- select idselectEmpPage resultTypecom.demo.vo.EmpVO SELECT e.emp_id, e.emp_no, e.emp_name, e.phone, d.dept_name, e.hire_date, e.status FROM emp_employee e LEFT JOIN sys_dept d ON e.dept_id d.dept_id WHERE e.del_flag 0 if testempName ! null and empName ! AND e.emp_name LIKE CONCAT(%, #{empName}, %) /if if teststatus ! null AND e.status #{status} /if ORDER BY e.emp_id DESC LIMIT #{offset}, #{pageSize} /select逻辑说明LEFT JOIN保证即使员工没分配部门也能查出来if动态拼接避免空条件污染 SQLLIMIT的两个参数由 Service 根据页码算出offset (pageNum - 1) * pageSize。参数上pageSize建议默认 10、上限 100防止有人传个 100000 把内存打爆。注意LIKE CONCAT(%, #{empName}, %)用的是#{}预编译不要图省事写成${}否则就是 SQL 注入的口子。3. 从零跑通环境搭建、配置与接口联调3.1 依赖版本怎么选才不打架SSM 项目最烦的就是版本冲突Spring 5.x 配 MyBatis 3.5.x 是经过大量项目验证的组合。下面这份pom.xml片段是常见做法版本号按你实际拉到的稳定版填。properties spring.version5.3.30/spring.version mybatis.version3.5.13/mybatis.version mybatis.spring.version2.1.2/mybatis.spring.version mysql.version8.0.33/mysql.version /properties dependencies !-- Spring 核心与 Web -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency !-- MyBatis 与整合包 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version${mybatis.spring.version}/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version${mysql.version}/version /dependency /dependencies参数说明mybatis-spring是整合的关键它提供了SqlSessionFactoryBean和MapperScannerConfigurer没有它 MyBatis 和 Spring 的事务管不到一起。MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver连接串要带serverTimezoneAsia/Shanghai否则时间字段会差 8 小时这个坑几乎人人踩过一次。3.2 Spring 与 MyBatis 整合配置的关键几行整合配置的核心是让 Spring 管理数据源和 SqlSessionFactory再扫描 Mapper 接口。下面用 Java 配置类的方式给出比 XML 更直观。Configuration MapperScan(com.demo.mapper) // 扫描 Mapper 接口生成代理实现 public class MyBatisConfig { Bean public DataSource dataSource() { DruidDataSource ds new DruidDataSource(); ds.setUrl(jdbc:mysql://localhost:3306/emp_db?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8); ds.setUsername(root); ds.setPassword(your_password); ds.setInitialSize(5); // 初始连接数 ds.setMaxActive(20); // 最大连接数按并发量调 return ds; } Bean public SqlSessionFactory sqlSessionFactory(DataSource ds) throws Exception { SqlSessionFactoryBean bean new SqlSessionFactoryBean(); bean.setDataSource(ds); // 指定 mapper xml 位置classpath 下按目录放 bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/*.xml)); // 开启驼峰命名自动映射emp_name - empName org.apache.ibatis.session.Configuration cfg new org.apache.ibatis.session.Configuration(); cfg.setMapUnderscoreToCamelCase(true); bean.setConfiguration(cfg); return bean.getObject(); } }逻辑说明MapperScan省去了给每个 Mapper 写MapperFactoryBean的麻烦setMapUnderscoreToCamelCase(true)让数据库的emp_name自动映射到实体的empName省掉大量resultMap。参数上maxActive不是越大越好20 到 50 之间对中小系统足够设太大反而会因为连接堆积拖垮数据库。initialSize设 5 是为了启动时预热避免第一个请求等连接。3.3 一个新增员工接口的完整链路从 Controller 到 Mapper 走一遍才能理解分层怎么落地。下面是新增员工的三段代码。// Controller只做参数接收和结果包装 RestController RequestMapping(/emp) public class EmpController { Autowired private EmpService empService; PostMapping(/add) public Result add(RequestBody Valid EmpDTO dto) { empService.addEmp(dto); return Result.success(); } }// Service业务校验 事务控制 Service public class EmpServiceImpl implements EmpService { Autowired private EmpMapper empMapper; Override Transactional(rollbackFor Exception.class) public void addEmp(EmpDTO dto) { // 工号重复直接抛业务异常事务回滚 if (empMapper.countByEmpNo(dto.getEmpNo()) 0) { throw new BizException(工号已存在); } EmpEmployee emp new EmpEmployee(); BeanUtils.copyProperties(dto, emp); emp.setStatus(1); emp.setDelFlag(0); empMapper.insert(emp); } }!-- Mapper只负责一条 insert -- insert idinsert parameterTypecom.demo.entity.EmpEmployee useGeneratedKeystrue keyPropertyempId INSERT INTO emp_employee (emp_no, emp_name, gender, phone, dept_id, post_id, hire_date, status, del_flag) VALUES (#{empNo}, #{empName}, #{gender}, #{phone}, #{deptId}, #{postId}, #{hireDate}, #{status}, #{delFlag}) /insert逻辑说明Transactional(rollbackFor Exception.class)指定任何异常都回滚默认只回滚运行时异常受检异常不回滚这是高频翻车点。useGeneratedKeystrue让插入后主键回填到实体方便后续关联操作。参数上Valid配合实体上的NotBlank、Pattern做前置校验能在进 Service 之前就挡掉一批脏数据。4. 避坑与排查SSM 员工管理系统最常见的五类问题4.1 中文乱码改了编码还是乱现象新增员工姓名存进数据库变成问号。原因通常有两处数据库连接串没带characterEncodingutf8或者表本身建的时候用了latin1。解决连接串补上useUnicodetruecharacterEncodingutf8建表统一用utf8mb4因为utf8存不了 emoji 和部分生僻字。改完记得重启应用连接池里的旧连接不会自动换编码。4.2 事务加了但不生效现象Service 里抛了异常数据还是插进去了。原因多半是同类内部方法调用比如 A 方法调本类的 B 方法B 上的Transactional不走代理等于没加。解决把事务方法抽到另一个 Service或者通过注入自身代理调用。另一个原因是异常被 catch 了没往外抛事务感知不到。4.3 分页总数和列表对不上现象列表显示 10 条总数却是 100。原因通常是 count 查询和 list 查询的 where 条件不一致比如 count 忘了加del_flag 0。解决把筛选条件抽成一个sql片段count 和 list 都include它从根上保证条件一致。4.4 逻辑删除后唯一索引冲突现象删了一个工号 A001 的员工再新增 A001 报唯一键冲突。原因逻辑删除只是把del_flag置 1记录还在唯一索引照样拦。解决要么把唯一索引改成(emp_no, del_flag)联合唯一要么删除时把工号改写成A001_del_时间戳。前者更干净推荐。4.5 时间字段差 8 小时现象入职日期存进去少一天或多 8 小时。原因MySQL 8 驱动默认时区是 UTC而服务器在东八区。解决连接串加serverTimezoneAsia/Shanghai同时确认数据库time_zone参数。这个坑排查起来最费劲因为代码逻辑完全正确问题全在配置。5. 让这套系统真正能用权限拦截与操作日志的落地技巧一套员工管理系统能不能交付往往不取决于 CRUD 写得多漂亮而取决于两件事谁能改、改了什么。这两点做扎实系统才敢给真实业务用。先说权限拦截。常见做法是基于角色的访问控制用户表挂角色角色挂菜单和按钮权限。落地时不要在每个 Controller 方法里手写if (没权限) return那样迟早漏。推荐用拦截器统一处理。// 权限拦截器在 preHandle 里校验当前用户是否持有该接口所需权限 public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { // 放行登录接口和静态资源 if (!(handler instanceof HandlerMethod)) return true; HandlerMethod hm (HandlerMethod) handler; RequirePerm rp hm.getMethodAnnotation(RequirePerm.class); if (rp null) return true; // 没标注解的不拦截 String need rp.value(); SetString perms (SetString) req.getSession() .getAttribute(perms); if (perms null || !perms.contains(need)) { resp.setStatus(403); return false; } return true; } }逻辑说明自定义注解RequirePerm(emp:edit)标在需要权限的方法上拦截器统一读取比对。参数上权限集合放 Session 里登录时一次性查出来避免每次请求都查库。注意拦截器要注册到WebMvcConfigurer的addInterceptors里并配好addPathPatterns和excludePathPatterns否则登录页也会被拦。再说操作日志。审计的关键是记录「谁、什么时候、对哪条数据、做了什么」。不要在每个 Service 里手写插入日志那样代码会脏得没法看。用 AOP 环绕通知配合自定义注解把日志采集和业务解耦。Aspect Component public class LogAspect { Autowired private OperLogMapper logMapper; Around(annotation(operLog)) public Object around(ProceedingJoinPoint pjp, OperLog operLog) throws Throwable { long start System.currentTimeMillis(); Object result pjp.proceed(); // 先执行原方法 // 方法正常返回后记录日志 OperLogEntity log new OperLogEntity(); log.setModule(operLog.module()); log.setAction(operLog.action()); log.setCostMs(System.currentTimeMillis() - start); log.setOperTime(new Date()); logMapper.insert(log); return result; } }逻辑说明Around在目标方法前后都能插逻辑这里选择方法成功返回后再记避免记录失败操作。参数上module和action由注解传入比如OperLog(module员工, action新增)。注意日志表要单独建且不要和业务表放同一个事务里否则业务回滚日志也没了审计就失去意义。最后给一个验证方法拿两个不同角色的账号一个只有查看权限一个能编辑分别去调同一个编辑接口看是否一个返回 403、一个成功再查操作日志表确认成功那次有记录、被拦那次没有。这套验证跑通系统才算真正能交付。我自己做这类后台有个习惯每加一个写操作先想清楚它该记什么日志、该要什么权限再动手写 CRUD。顺序反过来后期补权限和日志的代价比一开始就设计好要大得多。希望帮到你。本文还有配套的精品资源点击获取