简介:这份文档是一篇面向计算机专业毕业生与Java Web开发学习者的企业人事信息管理系统毕业论文,围绕SSM框架和MySQL数据库展开,可用于毕业设计选题、论文结构参考以及人事管理类系统的实现思路借鉴。压缩包内仅含1个doc文件,大小约1.19MB,集中呈现论文正文,包含中英文摘要、目录、关键词与章节编排,便于直接查阅、打印和整理。论文从研究背景、相关技术、系统分析与功能设计逐层展开,涵盖用户基础数据管理、薪资管理、福利管理、培训管理、任务管理和公告发布等模块,并涉及B/S结构、权限控制与数据备份策略,对SSM分层开发、数据库表设计和业务模块划分均有较完整的论述。已有84人学习,适合需要参考完整论文框架、梳理项目开发流程或补充人事系统功能设计细节的读者。
1. 一套 SSM 人事系统能解决的到底是什么活
接手一套基于 JavaWeb 的企业人事信息管理系统源码,我习惯先不看界面,而是看它有没有把「人、钱、事」三条线拆开。这套用 SSM(Spring + SpringMVC + MyBatis)加 MySQL 写的系统,把员工档案、薪资、福利、培训、任务、公告拆成六个独立模块,后端分层清楚,前端是 JSP 配后台模板,属于那种拿来能跑、改起来也不至于牵一发动全身的类型。它适合两类人:一类是要在有限时间里交出一份能演示、能讲清楚设计思路的完整工程;另一类是公司里想给 HR 做一套轻量内部工具的开发,不想上重型框架,也不想维护一堆服务。它不解决高并发,也没做复杂的权限模型,定位很明确——把散落在 Excel 里的人事表搬到浏览器里,再加上登录和角色约束。所以读它的正确姿势不是找亮点,而是看它哪一层薄、哪一层还能加东西。
2. 技术栈与工程结构:SSM 三层在这个项目里各自站哪一班
一套 JavaWeb 工程好不好改,取决于你打开 IDE 之后三十秒内能不能说出「请求从哪进、业务在哪写、SQL 在哪放」。这套系统的答案很传统,但传统有传统的好处:每一层都有明确的家,不存在一个方法里既查库又拼 JSON 还顺手改 Session 的情况。
2.1 为什么是 SSM 而不是一上来就 SpringBoot
项目选的是 Spring + SpringMVC + MyBatis 这套组合,打成 war 包丢进 Tomcat 跑。放在今天看有点老派,但对这类人事系统其实合适:模块数量固定、访问量小、部署环境往往是公司内网一台服务器,用不着 SpringBoot 那套自动装配和内置容器。更实际的一点是,JSP 渲染 + 表单提交的模式,对不熟悉前端工程化的开发者来说改起来成本最低——改个字段,JSP 上加一列、Controller 上加个参数、Mapper 里加个字段就完事,不用碰构建工具。
依赖清单是这类项目的标准配置,pom 里最关键的是版本对齐,Spring 4.x 和 MyBatis 3.x 混搭时版本错一位就会出现莫名其妙的NoSuchMethodError:
<properties> <spring.version>4.3.30.RELEASE</spring.version> <mybatis.version>3.5.6</mybatis.version> <mybatis.spring.version>1.3.3</mybatis.spring.version> <mysql.version>5.1.49</mysql.version> </properties> <dependencies> <!-- Spring 核心:IoC 容器 + 事务 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <!-- SpringMVC:DispatcherServlet 那一套 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- 事务管理,Service 层 @Transactional 靠它 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 与 Spring 的粘合层 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>${mybatis.spring.version}</version> </dependency> <!-- MySQL 驱动,5.x 对应 mysql 5.1.x 驱动,别和 8.x 混用 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.version}</version> </dependency> <!-- 连接池,Druid 带监控页,排查慢查询好用 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.1.24</version> </dependency> <!-- 分页插件,列表页全靠它 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.1.11</version> </dependency> </dependencies>参数上需要留意两处:mysql-connector-java的版本必须和数据库大版本对应,MySQL 5.7 用 5.1.x 驱动,URL 里写useUnicode=true&characterEncoding=utf8;pagehelper5.x 的拦截器配置方式和 4.x 不同,5.x 走PageInterceptor,写在 MyBatis 配置文件里而不是 Spring 配置里,网上大量旧教程还停留在 4.x,照抄必然不生效。我一般会把这两个版本号在 pom 顶部写成 property,改了地方一处生效。
2.2 目录结构与分层职责
标准的 Maven war 工程,src/main/java下按层分包,src/main/webapp下放 JSP 和静态资源。目录大概是这个样子:
src/main/java/com/example/hr ├── controller # 只做参数接收、调用 Service、返回视图或 JSON │ ├── UserController.java │ ├── SalaryController.java │ └── NoticeController.java ├── service # 业务规则在这里,比如薪资计算、培训天数换算 │ ├── UserService.java │ └── impl/UserServiceImpl.java ├── mapper # 只有接口,SQL 在 resources 下的同名 XML 里 │ └── UserMapper.java ├── entity # 与数据库表一一对应的 POJO │ └── User.java ├── interceptor # 登录拦截器 │ └── LoginInterceptor.java └── util # 分页封装、日期工具、MD5 等 src/main/resources ├── mapper # UserMapper.xml,和接口包名保持同构 ├── applicationContext.xml # 业务层 + 数据源 + 事务 ├── spring-mvc.xml # 控制器 + 视图解析 + 拦截器 ├── mybatis-config.xml # 别名、分页插件 └── jdbc.properties # 数据库连接参数 src/main/webapp ├── WEB-INF/jsp # 页面,放 WEB-INF 下防止被直接访问 └── static # js/css/图片这套结构的核心约定是「接口与 XML 同名同包」。com.example.hr.mapper.UserMapper对应resources/mapper/UserMapper.xml,MyBatis 的mapperLocations按这个路径扫,少一个层级就是后面第 5 章要讲的Invalid bound statement经典翻车点。
分层的纪律值得强调一下:Controller 里不写任何业务判断,Service 里不出现HttpServletRequest,Mapper 里不写if判断业务逻辑(动态 SQL 的if除外)。这条纪律听起来像教条,但它决定了你后面加一个字段时要改几个文件——守住了就改三个,守不住就得把整个链路读一遍。
2.3 三份配置文件怎么串起来
JavaWeb 项目最让人头大的就是配置分散。这套工程的启动链路是:web.xml加载 Spring 容器,Spring 容器管 Service 和 Mapper,SpringMVC 容器管 Controller,两个容器是父子关系。先看web.xml:
<!-- Spring 根容器:数据源、Service、事务,随应用启动 --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- SpringMVC 子容器:只负责 Controller 和视图 --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <!-- 编码过滤器必须放在最前面,否则后面所有中文处理都是补救 --> <filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>url-pattern写/而不是/*是关键:写/时静态资源交给容器的默认 Servlet 处理,JSP 和 css 能正常加载;写/*会把静态资源也交给 DispatcherServlet,页面样式全丢,这是新手最常问的「页面能打开但是白板」问题。
再看spring-mvc.xml里的视图解析和拦截器:
<!-- 开启注解扫描,只扫 controller 包,避免和根容器重复实例化 --> <context:component-scan base-package="com.example.hr.controller"/> <!-- JSP 视图解析:Controller 返回 "user/list" 会解析成 /WEB-INF/jsp/user/list.jsp --> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" value=".jsp"/> </bean> <!-- 登录拦截:排除登录页和静态资源 --> <mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.example.hr.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>这里有两个容易忽略的参数:component-scan的包路径要精确到 controller,否则根容器和子容器各扫一遍,Service 会被实例化两次,@Transactional在其中一个实例上失效,表现为「明明加了事务注解但回滚不了」;exclude-mapping必须包含登录接口本身,否则登录请求也被拦截,直接死循环重定向到登录页。
2.4 环境准备与第一次启动
我一般按这个顺序走,跳步最容易在驱动版本上卡住:
- JDK 8(项目用的是
source 1.8,用 JDK 17 跑 Tomcat 8 会有一堆模块访问警告); - Tomcat 8.5,war 包部署模式;
- MySQL 5.7,建库用
utf8mb4,别用utf8; - Maven 3.6,先把依赖拉到本地仓库。
数据库初始化是先建库再导表:
CREATE DATABASE hr_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE hr_system; -- 用户表:人事系统的主表,其他业务表的 yonghu_id 都指向它 CREATE TABLE yonghu ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', username VARCHAR(50) NOT NULL COMMENT '登录账号', password VARCHAR(64) NOT NULL COMMENT '密码,MD5 存储', name VARCHAR(50) COMMENT '真实姓名', sex_types INT COMMENT '性别,走字典表', phone VARCHAR(20) COMMENT '联系电话', bumen_types INT COMMENT '所属部门,走字典表', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) COMMENT '用户表';启动前把jdbc.properties里的账号密码改成自己的,mvn clean package打完包,把target/hr.war丢进tomcat/webapps,访问http://localhost:8080/hr/login。如果 404,先看 war 包名是不是和web.xml里的路径对得上,这个问题比你想的常见。
3. 数据库设计:六张核心表撑起人事业务
人事系统的数据库设计比代码更能看出作者的思路。这套系统的表结构有一个明显特征:所有业务表都挂一个yonghu_id或*_types字段,前者指向员工,后者指向字典表。理解了这个模式,你去看任何一张表都不会迷路。
3.1 E-R 关系:用户是中心,业务表挂在它下面
从设计文档里的 E-R 图能看出,用户实体处在中心位置,一对多连到培训、薪资、福利、任务、留言。这种星型结构的优点是查询简单——不管查哪个模块,先按yonghu_id过滤,再关联字典表翻译类型名。缺点是它假设了「一个人对应多条业务记录」,如果以后要做按部门汇总、按项目组拆分,就得在中层加一张组织表。
另外一个设计取舍值得说:书里提到的bumen_types(部门类型)没有单独建部门表,而是走了字典。对小规模系统来说这没问题,但当部门需要有自己的负责人、层级关系时,就不得不迁移成实体表。这类「先字典后实体」的演进路径,恰恰是二次开发时最常碰到的改造点。
3.2 建表语句:从培训表看字段设计的取舍
培训表是这套系统里比较典型的业务表,字段不多但覆盖了增删改查需要的全部信息:
CREATE TABLE cultivate ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', yonghu_id INT COMMENT '参训用户,关联 yonghu.id', on_time DATE COMMENT '培训开始时间', down_time DATE COMMENT '培训结束时间', day_number INT COMMENT '培训天数,由起止时间计算后冗余存储', cultivate_content VARCHAR(500) COMMENT '培训内容', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY idx_yonghu (yonghu_id) ) COMMENT '培训表';day_number这个冗余字段是刻意的:如果每次列表页都现场算DATEDIFF(down_time, on_time),数据量上来后排序和统计都会变慢,也写不出「按培训天数倒序」的索引。代价是起止时间一旦修改,这个字段必须同步更新,这个同步逻辑只能放在 Service 层,不能指望数据库。
公告表和福利表的写法类似,都是「名称 + 类型 + 内容 + 时间」四件套:
CREATE TABLE gonggao ( id INT PRIMARY KEY AUTO_INCREMENT, gonggao_name VARCHAR(200) COMMENT '公告标题', gonggao_types INT COMMENT '公告类型,走字典表', insert_time DATE COMMENT '公告日期', gonggao_content TEXT COMMENT '公告正文,富文本', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_type (gonggao_types) ) COMMENT '公告表'; CREATE TABLE fuli ( id INT PRIMARY KEY AUTO_INCREMENT, fuli_name VARCHAR(100) COMMENT '福利名称', bumen_types INT COMMENT '适用部门,走字典表', fuli_content VARCHAR(500) COMMENT '福利内容', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '福利表';参数上有两个细节:gonggao_content用TEXT而不是VARCHAR(500),因为公告正文是富文本,带 HTML 标签后长度不可控;insert_time用DATE而create_time用DATETIME,前者是业务日期(HR 自己选的发布日),后者是系统时间(入库那一刻),这两个语义不要混。
3.3 字典表:一个被低估的设计
字典表是整个结构里最不起眼但复用率最高的表:
CREATE TABLE dictionary ( id INT PRIMARY KEY AUTO_INCREMENT, dic_code VARCHAR(50) COMMENT '所属字段,如 sex_types、bumen_types', dic_name VARCHAR(50) COMMENT '字段中文名', code_index INT COMMENT '编码值,存进业务表的就是它', index_name VARCHAR(50) COMMENT '编码对应的显示名', super_id INT DEFAULT 0 COMMENT '父级 id,0 表示顶级', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_code_index (dic_code, code_index) ) COMMENT '字典表';业务表里存sex_types = 1,页面上要显示「男」,靠的就是一次dic_code = 'sex_types' AND code_index = 1的查询。这种做法在中小系统里非常划算:新增一个下拉选项只需要插一行数据,不用改代码、不用发版。代价是每次列表页都要额外查一次字典,常见的优化是在应用启动时把整张字典表加载进一个ConcurrentHashMap,按dic_code分组缓存,字典数据量通常只有几十上百行,全量缓存完全扛得住。
提示:
uk_code_index这个唯一索引别省。没有它,重复插入同一个编码值不会报错,页面上就会出现两个「男」,排查起来相当费劲。
3.4 分页与多条件查询的 SQL
列表页是人事系统里点击率最高的页面,几乎每个模块都是「关键字 + 类型 + 时间范围 + 分页」这套组合。MyBatis 里我一般这样写:
<select id="selectByCondition" resultType="com.example.hr.entity.Cultivate"> SELECT c.*, y.name AS yonghuName FROM cultivate c LEFT JOIN yonghu y ON c.yonghu_id = y.id <where> <if test="keyword != null and keyword != ''"> AND c.cultivate_content LIKE CONCAT('%', #{keyword}, '%') </if> <if test="yonghuId != null"> AND c.yonghu_id = #{yonghuId} </if> <if test="startTime != null"> AND c.on_time >= #{startTime} </if> <if test="endTime != null"> AND c.down_time <= #{endTime} </if> </where> ORDER BY c.create_time DESC </select><where>标签会自动处理掉第一个多余的AND,这是手写 SQL 拼接时最容易出错的地方。#{}是预编译占位符,能防注入;${}是字符串拼接,除非确认参数安全(比如排序字段),否则一律不用。分页交给 PageHelper,Service 里只写两行:
// 第一个参数是页码,第二个是每页条数,必须在查询前调用 PageHelper.startPage(pageNum, pageSize); List<Cultivate> list = cultivateMapper.selectByCondition(query); // 包装成 PageInfo 后,前端能直接拿到 total、pages、list PageInfo<Cultivate> pageInfo = new PageInfo<>(list);startPage只对紧随其后的第一条查询生效,这是血泪经验——中间插一句别的查询,分页就会作用到错误的语句上,表现出来就是总数对不上。
4. 核心功能实现:登录拦截、CRUD 与薪资计算的完整链路
功能实现这一层,我关注三件事:登录是不是真的拦住了、CRUD 是不是有统一的写法、算钱的逻辑放在了哪。这三件事决定了这套代码是「演示品」还是「能用」。
4.1 登录:从表单到 Session 的一次往返
登录接口本身很简单,关键是把密码校验和 Session 写入放在一起,且不返回用户密码:
@Controller public class LoginController { @Autowired private UserService userService; @RequestMapping(value = "/login", method = RequestMethod.POST) @ResponseBody public Map<String, Object> login(String username, String password, HttpServletRequest request) { Map<String, Object> result = new HashMap<>(); // 前端传来的是明文,这里先做 MD5 再比对,库里存的是摘要 User user = userService.login(username, DigestUtils.md5Hex(password)); if (user == null) { result.put("code", 500); result.put("msg", "账号或密码错误"); return result; } // 只放必要字段进 Session,别把整个 User 对象塞进去 request.getSession().setAttribute("loginUser", user); result.put("code", 200); result.put("msg", "登录成功"); return result; } }参数上有两点说明:@ResponseBody表示返回 JSON 而不是视图名,前端用 ajax 收结果再跳转,这样错误提示不用刷新页面;DigestUtils.md5Hex来自 Spring 的spring-core,不加盐的 MD5 在今天不算安全,但在这类内部系统里够用,真要加固就在 Service 里改成「盐值 + 多次摘要」,改一处即可。
4.2 拦截器:没有它,登录就是摆设
只写登录不写拦截,等于把后门敞开——任何人直接敲/user/list就能进列表页。拦截器的实现只有十几行,但它是整个权限体系的地基:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { // 区分 ajax 和页面请求:ajax 返回 JSON,页面请求重定向 String header = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(header)) { response.setContentType("application/json;charset=utf-8"); response.getWriter().write("{\"code\":401,\"msg\":\"登录已失效\"}"); } else { response.sendRedirect(request.getContextPath() + "/login"); } return false; } return true; } }preHandle返回false表示中断请求链,这是拦截生效的标志。区分 ajax 和页面请求很重要:ajax 请求如果被重定向到登录页,前端拿到的是登录页的 HTML,JSON.parse直接报错,用户看到的是一个诡异的解析异常而不是「请重新登录」。
4.3 用户管理 CRUD:一个 Controller 的标准写法
六个模块的 Controller 几乎是同一个模板,看一个就够。下面是用户管理的核心片段:
@Controller @RequestMapping("/user") public class UserController { @Autowired private UserService userService; // 列表:分页 + 关键字,返回视图 @RequestMapping("/list") public String list(UserQuery query, Model model) { PageInfo<User> page = userService.page(query); model.addAttribute("page", page); model.addAttribute("query", query); return "user/list"; // 解析成 /WEB-INF/jsp/user/list.jsp } // 新增:先校验必填,再落库 @RequestMapping("/add") @ResponseBody public Map<String, Object> add(User user) { Map<String, Object> r = new HashMap<>(); if (StringUtils.isBlank(user.getUsername())) { r.put("code", 500); r.put("msg", "账号不能为空"); return r; } // 初始密码统一给默认值,强制首次登录修改是更稳的做法 user.setPassword(DigestUtils.md5Hex("123456")); userService.save(user); r.put("code", 200); return r; } // 删除:物理删除,调用前前端必须二次确认 @RequestMapping("/delete") @ResponseBody public Map<String, Object> delete(Integer id) { userService.removeById(id); return Collections.singletonMap("code", 200); } }Model用于页面渲染传值,@ResponseBody用于 ajax 返回 JSON,两种返回方式在同一个 Controller 里共存是这套代码的常态。参数UserQuery是一个封装了pageNum、pageSize、keyword的查询对象,SpringMVC 会自动按字段名绑定,省去一堆@RequestParam。
4.4 薪资管理:算钱这件事不能放在前端
薪资模块最容易翻车的地方是把计算写在 JSP 里。前端算出来的数字可以随便改,一旦涉及发放记录,账就对不上。这套系统把计算放在 Service,并且加了事务:
@Service public class SalaryServiceImpl implements SalaryService { @Autowired private SalaryMapper salaryMapper; @Override @Transactional(rollbackFor = Exception.class) public void issue(SalaryIssueDTO dto) { // 1. 取员工基础薪资 Salary base = salaryMapper.selectByUserId(dto.getYonghuId()); if (base == null) { throw new RuntimeException("该员工未配置薪资基数"); } // 2. 应发 = 基本工资 + 绩效 - 社保代扣,全部用 BigDecimal BigDecimal gross = base.getBasePay() .add(dto.getPerformance() == null ? BigDecimal.ZERO : dto.getPerformance()) .subtract(base.getInsurance()); // 3. 写发放流水,同时把状态改为已发放 SalaryRecord record = new SalaryRecord(); record.setYonghuId(dto.getYonghuId()); record.setAmount(gross); record.setIssueTime(new Date()); salaryMapper.insertRecord(record); salaryMapper.updateStatus(base.getId(), 1); } }@Transactional(rollbackFor = Exception.class)里的rollbackFor不能省,Spring 默认只对RuntimeException回滚,抛出受检异常时事务不会回滚,钱扣了记录没写。金额一律用BigDecimal且用add/subtract而不是+ -,double在金额计算上的精度问题属于玄学范畴,出一次对不上账就得查一整天。
4.5 公告与富文本:发布出去的东西要能被搜到
公告模块相对简单,但有一个细节常被忽略:富文本编辑器提交上来的内容是带 HTML 标签的字符串,直接存库没问题,展示时必须用<c:out value="${...}" escapeXml="false"/>,否则页面上看到的是一堆<p>标签。反过来,如果公告允许普通员工提交,就必须做 XSS 过滤,白名单放行<p>、<br>、<strong>这几个标签就够了。列表页的搜索框则要在 SQL 里对gonggao_name建索引,否则公告一多,LIKE '%关键字%'全表扫描,翻页会明显卡顿。
5. 避坑与排查:这套源码在我机器上翻过的五次车
同一个 war 包,在别人机器上一次启动,在我这里能连着报五个不同的错。这一章按「现象 → 原因 → 解决」把踩过的坑记下来,你遇到同款可以直接对号入座。
5.1 启动后访问 404:war 包名和访问路径对不上
现象:Tomcat 启动日志里没有异常,webapps下也确实解压出了目录,但浏览器访问http://localhost:8080/是 404。
原因:war 包的文件名决定了 context path。如果打出来的是hr-1.0-SNAPSHOT.war,访问路径就是/hr-1.0-SNAPSHOT,而不是/hr。项目里所有sendRedirect和静态资源引用如果写死了/hr,全都会失效。
解决:在 pom 的<build><finalName>里固定成hr,重新打包;或者干脆把 war 改名再丢进去。另外server.xml里不要手动配<Context path="">,和 war 包名冲突时会出现两个上下文互相覆盖。
5.2 中文乱码:三处编码要一起改,改一处没用
现象:登录页输入中文用户名能登进去,但列表页显示的姓名是问号;或者表单提交后数据库里存的是乱码。
原因:编码链路上有三个位置:Tomcat 的URIEncoding、CharacterEncodingFilter、JDBC URL 的characterEncoding。只改一个,另外两个仍然按 ISO-8859-1 处理。
解决:Tomcat 的conf/server.xml里 Connector 加URIEncoding="UTF-8";web.xml的过滤器加forceEncoding=true;jdbc.properties的 URL 加useUnicode=true&characterEncoding=utf8;建库时字符集用utf8mb4。四个位置一起改,改完清一次浏览器缓存再测。
5.3 Invalid bound statement:Mapper XML 没被扫到
现象:启动正常,一点列表就报org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)。
原因:有三种可能——XML 放在了src/main/java下没被 Maven 打包进去;mapperLocations的路径和实际路径差一层;XML 里的namespace和接口全限定名不一致。这个错误信息不给具体文件名,属于典型的黑匣子。
解决:先在target/classes下确认 XML 到底有没有被打进去,没有就在 pom 的<resources>里把src/main/java下的**/*.xml包含进来;路径确认用classpath*:mapper/*.xml;最后核对namespace,一个字符都不能差。
注意:IDEA 里改完 XML 有时不会重新编译到 target,热部署下看起来像「改了没用」,执行一次
mvn clean再启动能省掉大量自我怀疑。
5.4 表单提交报 400:日期格式和实体字段类型对不上
现象:新增培训记录时页面直接返回 400,控制台没有业务异常堆栈。
原因:JSP 的日期控件提交的是2024-05-01这样的字符串,实体里字段是Date类型,SpringMVC 默认的转换器不认这个格式,绑定失败直接 400。
解决:在 Controller 的字段上加@DateTimeFormat(pattern = "yyyy-MM-dd"),或者在spring-mvc.xml里注册一个全局的FormattingConversionServiceFactoryBean。全局方案更省事,因为这个格式在这个项目里被用了十几次。
5.5 删除用户后薪资记录变成孤儿数据
现象:删掉一个离职员工,薪资列表里还挂着这个人的发放记录,但姓名列显示空白。
原因:业务表用了yonghu_id关联,但建表时没加外键约束(这类项目通常都不加),删除只删了主表。
解决:删除逻辑改成软删除,给yonghu加is_deleted字段,列表查询统一加AND is_deleted = 0;或者删之前先检查关联表有没有记录,有就提示「该员工存在业务数据,不能删除」。我一般选软删除,后悔药好找。
6. 二次开发:把「能跑」改成「能用」的三个切入点
第一个切入点是加模块。以考勤为例,最小改动面是固定的五个文件,别想着一次改完,按这个顺序来最省事:
| 顺序 | 文件 | 改什么 |
|---|---|---|
| 1 | sql/kaoqin.sql | 建表,字段照抄cultivate的结构改语义 |
| 2 | entity/Kaoqin.java | 和表字段一一对应,日期字段加@DateTimeFormat |
| 3 | mapper/KaoqinMapper.java+ XML | 只写selectByCondition、insert、update、deleteById四个 |
| 4 | service/KaoqinService.java | 业务校验放这里,比如同一人同一天不能重复打卡 |
| 5 | controller/KaoqinController.java+ JSP | 照抄CultivateController改字段名 |
改完先别急着点页面,用curl或者 Postman 直接打接口,确认返回的 JSON 结构对,再改 JSP。这个顺序能让你在「页面白屏」和「SQL 报错」之间快速定位问题。
第二个切入点是权限。现在这套是单角色——登录了就是管理员,所有菜单都可见。要扩成多角色,最小改法是给yonghu加role_types字典字段,在登录时把角色一并放进 Session,然后写一个@RoleAllowed注解配拦截器,在preHandle里比对。不要一上来就引 Shiro 或 Spring Security,那套东西的学习成本比这个系统本身还高。
第三个切入点是验证。每改一处,我强制自己走一遍清单:登录 → 目标模块列表 → 新增 → 编辑 → 删除 → 退出 → 直接访问 URL 看是否被拦截。第三到第五步最容易漏,尤其是「编辑后列表没刷新」这种缓存问题,测一次比看十遍代码快。
说个教训。早些年我拿到这类源码,总喜欢先重构工程结构,觉得包名不规范、命名太随意,结果改了两天没跑起来,反而把原来能跑的版本弄坏了。从那以后我每次拿到陌生源码,都强制先原样跑通一遍、跑通之前不动任何一行代码,跑通之后再拿 git 开一个分支做改造。这个习惯救过我很多次。希望帮到你。
本文还有配套的精品资源,点击获取