ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SSM员工管理系统实战:从数据库设计到前后端联调完整落地

SSM员工管理系统实战:从数据库设计到前后端联调完整落地 简介这份资源是一套基于SSMSpringSpringMVCMyBatis架构的员工管理系统完整项目面向JavaWeb初学者、课程设计或毕业设计开发者帮助解决企业员工信息管理信息化、规范化的实际需求。系统采用JSPJavaScriptJQuery作为前端语言MySQL作为数据库开发环境为IDEA2019服务器使用Tomcat涵盖员工管理、薪酬管理、用户管理、通知管理、文件管理等核心模块并区分超级管理员、普通管理员、临时管理员三种角色权限。压缩包共447个文件约93.17MB包含44个java源码、49个jsp页面、53个xml配置、28个css样式、40张jpg图片以及1个sql建库脚本和1个war部署包另附配套文档与说明结构完整便于二次开发。目前已有413人学习下载适合需要掌握SSM整合、MVC分层设计与数据库实战的读者参考借鉴。1. 基于SSM架构的员工管理系统从建表到前后端联调一套能跑通的落地路径很多团队在做一个内部员工管理系统时第一反应是上微服务、上Vue3SpringBoot3结果两周过去连登录都没跑通。我见过太多这样的翻车现场需求本身只是增删改查加权限却因为技术栈选型过重把时间全耗在环境配置和版本兼容上。SSMSpring SpringMVC MyBatis这套组合虽然听起来“老”但它在中小型管理系统里的稳定性、资料完整度和排错友好度至今没有几个框架能替代。这篇文章面向的是需要交付一套完整员工管理系统的开发者——不管你是接了个毕设、公司内部工具还是想拿一个真实项目练手前后端联调。我会从数据库表结构设计开始一路讲到Controller层怎么写、前端怎么对接、SQL脚本怎么导入、以及那些只有踩过才知道的坑。整套方案包含完整前后端代码、配套文档和可执行的SQL脚本你照着做就能跑起来。2. 先把表结构定死员工管理系统的数据库设计与SQL脚本落地2.1 为什么表结构要先于代码确定员工管理系统的核心表其实不多但每张表的字段设计直接决定了后面Mapper写起来顺不顺。我一般会先把五张核心表定下来部门表department、职位表position、员工表employee、用户表sys_user、角色权限表sys_role。其中员工表和用户表的关系是最容易出问题的地方——很多新手会把登录账号和员工信息塞进同一张表结果后面做权限控制时发现字段耦合太严重改一个字段要动半个系统。正确的做法是员工表只存业务信息姓名、工号、部门、职位、入职时间、薪资等用户表只存认证信息用户名、密码、角色ID、状态两张表通过employee_id关联。这样后面加一个“员工离职后账号自动禁用”的功能只需要改用户表的状态字段不会影响员工档案数据。另一个关键点是部门表和员工表的关联方式。常见做法是在员工表里存department_id作为外键但如果你要做部门树形结构比如“技术部”下面有“前端组”“后端组”就需要在部门表里加parent_id字段做自关联。这个设计在SQL层面用递归查询或者程序层面递归组装都可以但表结构必须先支持。2.2 建表SQL脚本与字段说明下面是我在实际项目中用的建表脚本以MySQL为例字符集统一用utf8mb4引擎InnoDB。注意每个字段的注释都要写清楚后面生成文档和排错时能省大量时间。-- 部门表支持树形结构 CREATE TABLE department ( dept_id INT NOT NULL AUTO_INCREMENT COMMENT 部门ID, dept_name VARCHAR(50) NOT NULL COMMENT 部门名称, parent_id INT DEFAULT 0 COMMENT 父部门ID0为顶级, sort_order INT DEFAULT 0 COMMENT 排序, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT部门表; -- 职位表 CREATE TABLE position ( pos_id INT NOT NULL AUTO_INCREMENT COMMENT 职位ID, pos_name VARCHAR(50) NOT NULL COMMENT 职位名称, dept_id INT DEFAULT NULL COMMENT 所属部门ID, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (pos_id), KEY idx_dept (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT职位表; -- 员工表只存业务信息 CREATE TABLE employee ( emp_id INT NOT NULL AUTO_INCREMENT COMMENT 员工ID, emp_no VARCHAR(20) NOT NULL COMMENT 工号唯一, emp_name VARCHAR(30) NOT NULL COMMENT 姓名, gender TINYINT DEFAULT 1 COMMENT 性别 1男 2女, phone VARCHAR(15) DEFAULT NULL COMMENT 手机号, email VARCHAR(50) DEFAULT NULL COMMENT 邮箱, dept_id INT DEFAULT NULL COMMENT 部门ID, pos_id INT DEFAULT NULL COMMENT 职位ID, hire_date DATE DEFAULT NULL COMMENT 入职日期, salary DECIMAL(10,2) DEFAULT NULL COMMENT 薪资, status TINYINT DEFAULT 1 COMMENT 状态 1在职 0离职, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (emp_id), UNIQUE KEY uk_emp_no (emp_no), KEY idx_dept (dept_id), KEY idx_name (emp_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT员工表; -- 用户表只存认证信息 CREATE TABLE sys_user ( user_id INT NOT NULL AUTO_INCREMENT, username VARCHAR(30) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT 密码加密存储, emp_id INT DEFAULT NULL COMMENT 关联员工ID, role_id INT DEFAULT NULL COMMENT 角色ID, status TINYINT DEFAULT 1 COMMENT 1启用 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id), UNIQUE KEY uk_username (username), KEY idx_emp (emp_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT系统用户表; -- 角色表 CREATE TABLE sys_role ( role_id INT NOT NULL AUTO_INCREMENT, role_name VARCHAR(30) NOT NULL COMMENT 角色名, role_key VARCHAR(30) NOT NULL COMMENT 权限标识, PRIMARY KEY (role_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT角色表;这段脚本里有几个参数值得单独说。emp_no加了唯一索引因为工号重复在业务上不可接受让数据库层兜底比在代码里查重更可靠。salary用DECIMAL而不是FLOAT避免浮点精度问题——这个坑我在薪资统计模块上踩过用FLOAT算总和会出现0.01的偏差财务对账时非常尴尬。update_time用了ON UPDATE CURRENT_TIMESTAMP这样每次更新记录时数据库自动维护时间戳不用在Java代码里手动set。导入脚本时如果你用Navicat直接右键数据库选“运行SQL文件”即可。用命令行的话mysql -u root -p employee_db employee_init.sql执行前先确认数据库已创建CREATE DATABASE employee_db DEFAULT CHARSET utf8mb4;否则会报“Unknown database”错误。另外注意SQL文件编码要存为UTF-8无BOM格式否则中文注释会变成乱码这个坑在Windows上用记事本编辑SQL文件时特别常见。2.3 初始化数据与索引策略建完表之后要插入基础数据至少一个管理员角色、一个admin用户、几个测试部门。密码字段不要存明文用MD5加盐或者BCrypt。如果是毕设或内部工具MD5加固定盐就够了如果对安全性有要求用BCryptSpring Security里直接有BCryptPasswordEncoder。索引方面员工表上emp_name建了普通索引因为按姓名搜索是高频操作。但注意不要滥用索引——比如gender字段只有两个值建索引反而拖慢写入。status字段同理区分度太低。真正需要索引的是dept_id、emp_no、hire_date这类区分度高且经常出现在WHERE条件里的字段。还有一个容易忽略的点如果后面要做分页查询ORDER BY create_time DESC配合LIMIT时create_time上最好有索引否则数据量到几万条时翻页会明显变慢。这个在开发阶段感觉不到上线后就是慢SQL的典型来源。3. 后端分层怎么写SpringMyBatis的Mapper、Service、Controller落地3.1 项目结构与依赖配置SSM项目的目录结构我一般这样组织controller层只做参数校验和路由转发service层写业务逻辑和事务控制mapper层只做数据库操作entity放实体类dto放前后端交互的数据传输对象。不要在Controller里直接调Mapper也不要在Service里写SQL——分层一旦破了后面改需求就是灾难。Maven依赖的核心是这几个spring-webmvc、spring-jdbc、mybatis、mybatis-spring、druid连接池、mysql-connector-java、jackson-databind、log4j2。版本上Spring用5.x系列MyBatis用3.5.xmysql驱动用8.x对应MySQL 8。注意mysql-connector-java 8.x的驱动类名是com.mysql.cj.jdbc.Driver不是老的com.mysql.jdbc.Driver写错会报ClassNotFoundException。!-- pom.xml 核心依赖片段 -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.30/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.1.2/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency数据库连接配置放在jdbc.properties里用Druid连接池。关键参数initialSize5、maxActive20、minIdle5、maxWait60000。maxWait设60秒是防止连接池耗尽时请求无限等待。validationQuery用SELECT 1testWhileIdletrue这样连接空闲时会被检测避免MySQL的8小时超时断连问题——这个坑在测试环境经常遇到表现为第二天早上第一个请求必然报“Communications link failure”。3.2 MyBatis的Mapper XML与动态SQL员工列表查询通常需要支持多条件组合按姓名模糊搜、按部门筛选、按状态筛选、分页。这种场景用MyBatis的动态SQL最合适if和where标签组合起来写。!-- EmployeeMapper.xml 查询片段 -- select idselectByCondition resultMapEmployeeResultMap SELECT e.*, d.dept_name, p.pos_name FROM employee e LEFT JOIN department d ON e.dept_id d.dept_id LEFT JOIN position p ON e.pos_id p.pos_id where if testempName ! null and empName ! AND e.emp_name LIKE CONCAT(%, #{empName}, %) /if if testdeptId ! null AND e.dept_id #{deptId} /if if teststatus ! null AND e.status #{status} /if /where ORDER BY e.create_time DESC LIMIT #{offset}, #{pageSize} /select这里有几个细节。LIKE CONCAT(%, #{empName}, %)用的是预编译参数不是字符串拼接能防SQL注入。如果用${}拼接攻击者输入 OR 11就能绕过条件——这是SQL注入最基础的形态但每年还是有项目栽在这上面。LEFT JOIN保证即使员工没分配部门或职位记录也能查出来不会因为INNER JOIN丢数据。分页参数offset和pageSize由Service层计算offset (pageNum - 1) * pageSize。注意MySQL的LIMIT是从0开始计数的第一页offset为0。如果前端传pageNum1算出来offset0这个逻辑要在Service里写清楚不要指望前端传对。对应的Mapper接口方法签名ListEmployee selectByCondition(Param(empName) String empName, Param(deptId) Integer deptId, Param(status) Integer status, Param(offset) Integer offset, Param(pageSize) Integer pageSize);Param注解不能省否则XML里用#{empName}会找不到参数。当方法有多个参数时MyBatis默认用param1、param2或arg0、arg1来引用可读性极差加Param是标准做法。3.3 Service层事务与Controller参数接收Service层用Transactional注解控制事务。新增员工时如果同时要创建登录账号两个insert操作必须在同一个事务里——要么都成功要么都回滚。否则可能出现员工记录插入了但账号没创建或者反过来。Service public class EmployeeServiceImpl implements EmployeeService { Autowired private EmployeeMapper employeeMapper; Autowired private SysUserMapper sysUserMapper; Override Transactional(rollbackFor Exception.class) public void addEmployee(Employee employee, String username) { // 先插员工 employeeMapper.insert(employee); // 再建账号emp_id回填 SysUser user new SysUser(); user.setUsername(username); user.setPassword(Md5Util.encode(123456)); // 默认密码 user.setEmpId(employee.getEmpId()); user.setRoleId(2); // 普通员工角色 sysUserMapper.insert(user); } }rollbackFor Exception.class必须加因为Spring默认只对RuntimeException回滚如果抛的是Checked Exception比如IOException事务不会回滚。这个坑我在文件导入员工数据的功能上踩过——解析Excel出错抛了IOException员工记录已经插了一半账号没建数据就脏了。Controller层接收前端JSON用RequestBody接收表单参数用RequestParam。返回统一用ResultT包装包含code、msg、data三个字段。这样前端处理响应时逻辑统一不用每个接口单独判断。RestController RequestMapping(/api/employee) public class EmployeeController { Autowired private EmployeeService employeeService; PostMapping(/add) public Result add(RequestBody EmployeeDTO dto) { // 参数校验 if (StringUtils.isBlank(dto.getEmpName())) { return Result.error(员工姓名不能为空); } employeeService.addEmployee(dto.toEmployee(), dto.getUsername()); return Result.success(); } GetMapping(/list) public Result list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, String empName, Integer deptId, Integer status) { int offset (pageNum - 1) * pageSize; ListEmployee list employeeService.selectByCondition(empName, deptId, status, offset, pageSize); int total employeeService.countByCondition(empName, deptId, status); MapString, Object data new HashMap(); data.put(list, list); data.put(total, total); return Result.success(data); } }RestController等于Controller加ResponseBody返回的对象自动序列化成JSON。RequestMapping(/api/employee)统一了前缀前端调用时路径清晰。分页返回total是为了前端分页组件能算出总页数这个字段不能省。4. 前后端联调接口对接、跨域处理与按钮重复提交校验4.1 前端请求封装与跨域配置前端不管用Vue还是jQuery核心是把请求封装成统一方法。以axios为例创建一个request.js设置baseURL和拦截器。拦截器里统一处理token、错误提示和loading状态。// request.js import axios from axios; const service axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截器带上token service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; }); // 响应拦截器统一处理错误 service.interceptors.response.use( response { const res response.data; if (res.code ! 200) { alert(res.msg || 请求失败); return Promise.reject(new Error(res.msg)); } return res.data; }, error { alert(网络异常请稍后重试); return Promise.reject(error); } ); export default service;跨域问题在前后端分离项目里必然遇到。开发阶段前端跑在8080端口后端跑在8081浏览器会拦截跨域请求。解决方案有两种后端加CORS配置或者前端配代理。我一般两个都做——后端加CORS是兜底前端配代理是为了开发方便。后端CORS配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns和allowedOrigins的区别当allowCredentials(true)时allowedOrigins不能设为*必须用allowedOriginPatterns。这个细节在Spring 5.3之后才支持老版本会直接报错。前端代理配置vue.config.jsmodule.exports { devServer: { proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } };这样前端请求/api/employee/list会被代理到http://localhost:8081/api/employee/list浏览器认为同源不会拦截。4.2 按钮重复提交的三种校验方案按钮重复提交是管理系统里最烦人的问题之一。用户网络慢点了一下“保存”没反应又点了一下结果插了两条相同数据。解决方案从简单到复杂有三种。第一种前端按钮点击后立即禁用请求返回后再启用。这是最基础的但防不住用户刷新页面后重新提交。// 提交按钮防重复 let submitting false; async function handleSubmit() { if (submitting) return; submitting true; document.getElementById(submitBtn).disabled true; try { await service.post(/employee/add, formData); alert(保存成功); } finally { submitting false; document.getElementById(submitBtn).disabled false; } }第二种后端用token机制。页面加载时后端生成一个唯一token存到Redis前端提交时带上这个token后端校验并删除。同一个token第二次提交时已经不存在直接拒绝。这个方案能防住刷新页面重复提交但需要引入Redis。第三种数据库唯一约束兜底。比如员工表的emp_no加了唯一索引即使前面两种都失效重复插入时数据库会抛DuplicateKeyException后端捕获后返回“工号已存在”。这是最后一道防线也是最可靠的。我一般三种都用前端禁用按钮提升体验后端token防刷新数据库唯一索引兜底。三层防护下来重复提交基本不可能发生。4.3 接口联调时常见的参数对不上问题前后端联调最耗时的不是写代码是参数对不上。前端传{empName: 张三}后端用RequestParam String empName接收如果前端用的是JSON body后端用RequestParam就收不到必须用RequestBody配合DTO。这个错误的表现是后端参数为null但前端明明传了。另一个常见问题是日期格式。前端传2024-01-15后端实体类里是java.util.DateMyBatis插入时可能报格式转换错误。解决方案是在实体类的日期字段上加JsonFormat(pattern yyyy-MM-dd, timezone GMT8)同时MyBatis的jdbcType设为DATE。还有中文乱码问题。前端POST请求的Content-Type要设为application/json;charsetUTF-8后端在web.xml里配CharacterEncodingFilterforceEncoding设为true。Tomcat 8之后默认URI编码是UTF-8但请求体编码还是要显式配。5. 避坑与排查SSM员工管理系统落地时最容易翻车的5个地方5.1 现象启动报“No qualifying bean of type ‘xxxMapper’”原因MyBatis的Mapper接口没有被Spring扫描到。常见于Mapper接口和XML文件不在同一个包路径下或者MapperScan注解写错了包名。解决在Spring配置类上加MapperScan(com.example.mapper)确保包路径和实际Mapper接口所在包一致。如果XML文件放在resources/mapper目录下需要在applicationContext.xml里配mapperLocations为classpath:mapper/*.xml。另外检查Maven的resources插件有没有把XML文件打包进去——默认情况下src/main/java下的XML不会被编译到classpath需要在pom里配resources包含**/*.xml。5.2 现象查询返回的字段全是null但数据库里明明有数据原因实体类的字段名和数据库列名不一致且没有配resultMap或mapUnderscoreToCamelCase。比如数据库列是emp_name实体类字段是empNameMyBatis默认不会自动转换。解决在MyBatis配置文件里开启驼峰映射setting namemapUnderscoreToCamelCase valuetrue/。或者手动写resultMap把每个列和字段的对应关系写清楚。我一般两个都做——开启驼峰映射处理大部分情况resultMap处理特殊映射比如关联查询的dept_name映射到deptName。5.3 现象事务不回滚插入了一半数据原因Transactional注解没生效。常见原因有三个方法不是public的、同类内部方法调用、异常类型不在回滚范围内。解决确保注解方法为public如果是内部调用把方法抽到另一个Service里或者用AopContext.currentProxy()获取代理对象注解上明确写rollbackFor Exception.class。另外检查Spring的事务管理器有没有配——bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager并开启tx:annotation-driven/。5.4 现象前端请求返回302或401但接口本身没问题原因拦截器或过滤器把请求拦了。SSM项目里常见的是配了登录拦截器但拦截路径写成了/**把登录接口本身也拦了导致死循环。解决拦截器配置里排除登录接口和静态资源.excludePathPatterns(/api/login, /static/**, /css/**, /js/**)。如果是Shiro或Spring Security检查filterChainDefinitionMap的配置顺序anon放行要放在authc之前。5.5 现象SQL脚本导入后中文变成问号原因数据库、表、连接三层的字符集不一致。常见于数据库建的时候用了latin1或者JDBC连接URL没指定characterEncoding。解决建库时指定DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ciJDBC URL加上?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai导入SQL文件时确认文件本身是UTF-8编码。如果已经导入乱码了只能删库重建没有后悔药。6. 进阶技巧用SQL窗口函数做薪资排名与部门人数统计基础功能跑通之后员工管理系统通常会被要求加一些统计报表。比如“按部门统计人数和平均薪资”“薪资在部门内的排名”。这些用Java代码循环算也能做但数据量大了性能很差。SQL窗口函数是更优雅的方案MySQL 8.0和SQL Server 2012以上都支持。先看部门人数和平均薪资的统计SELECT d.dept_name, COUNT(e.emp_id) AS emp_count, ROUND(AVG(e.salary), 2) AS avg_salary, MAX(e.salary) AS max_salary FROM department d LEFT JOIN employee e ON d.dept_id e.dept_id AND e.status 1 GROUP BY d.dept_id, d.dept_name ORDER BY emp_count DESC;注意LEFT JOIN的条件里加了e.status 1只统计在职员工。如果写在WHERE里会把没有在职员工的部门过滤掉但业务上可能希望显示人数为0的部门。这个区别在报表场景下很关键。再看薪资在部门内的排名用窗口函数SELECT emp_name, dept_id, salary, RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS salary_rank, DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS dense_rank FROM employee WHERE status 1;RANK()和DENSE_RANK()的区别在于并列名次后的编号RANK遇到并列会跳号1,1,3DENSE_RANK不跳号1,1,2。做薪资排名时用DENSE_RANK更符合直觉因为不会出现“没有第2名”的尴尬。如果要在MyBatis里用窗口函数直接写在XML的select里就行返回结果映射到一个DTO类。注意MySQL 5.7不支持窗口函数如果项目用的是5.7版本只能用子查询模拟SELECT e1.emp_name, e1.dept_id, e1.salary, (SELECT COUNT(DISTINCT e2.salary) FROM employee e2 WHERE e2.dept_id e1.dept_id AND e2.salary e1.salary) AS salary_rank FROM employee e1 WHERE e1.status 1;这个子查询的写法在数据量大时性能明显下降因为每行都要执行一次子查询。所以如果条件允许尽量把MySQL升级到8.0。还有一个实用技巧是慢SQL排查。在MySQL里开启慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;然后看日志里哪些SQL执行超过1秒。员工管理系统里最容易慢的是不带索引的模糊搜索和没有LIMIT的全表查询。用EXPLAIN分析执行计划看type列是不是ALL全表扫描如果是检查WHERE条件里的字段有没有索引。我自己在这个项目上最大的教训是一开始觉得表结构随便建建就行后面改需求时加字段、改关联SQL脚本改了七八版每次都要重新导数据。后来学乖了建表之前先把所有查询场景列出来反推需要哪些字段和索引一次定好。这个习惯帮我省了大量返工时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表