简介:面向实验室管理场景的Java Web毕业设计文档,基于JSP与SSM框架(Spring、SpringMVC、MyBatis)设计开发,使用MySQL数据库和B/S结构,覆盖实验室、设备、器材等资源的前后台管理流程。压缩包内只有1个doc文件,整体约1.41MB,但内容完整:从课题背景、国内外研究现状、技术选型与可行性分析,到系统流程、用例分析、数据库实体与表设计,再到用户登录、管理员管理、用户管理、实验室添加等主要功能界面的实现均有细致展开。并配有清晰的目录与章节安排,便于按需查看设计思路、数据库脚本说明和模块编码位置。文档末尾还保留了完整参考目录。已有293人学习下载;对于正在做Java Web课程设计、毕业设计或需要快速搭建实验室管理系统的开发者,可作为需求梳理、架构参考和编码实现的直接蓝本。
1. 这个标题在讲什么:SSM + JSP 的实验室管理系统
标题里的"现实"十有八九是"实现"的笔误。这类 doc 在毕设和课设里几乎和"学生管理系统"一个待遇:基于 JSP 的 SSM 实验室管理系统,本质是一套典型 Java Web CRUD 加上预约流转。它解决的是实验室管理里最头疼的三件事——谁在用、什么时候用、设备还在不在。技术栈选 SSM(Spring + SpringMVC + MyBatis)加 JSP,不是因为它新,而是因为它恰好是 Java Web 毕设选题里最稳妥的配置:SSM 把数据访问和业务逻辑分层收干净,JSP 把数据渲染成老师一眼能懂的页面。适合正在选基于 JSP 的毕设题目的学生、要接手老项目的初级工程师,以及想快速搭一套带预约审批流程内部系统的人。这套组合完整踩过 Java Web 发展史的各个节点,读懂一个 SSM + JSP 项目,等于把 Servlet、Filter、MVC、ORM 的底层机制重新捋了一遍。
2. 先把架构立住:SSM 整合 JSP 的分层逻辑与数据库设计
2.1 Model2 思想:JSP 在 SSM 里到底扮演什么角色
JSP Model2 思想(很多 JavaWeb 实训里都要求"按照 JSP Model2 思想实现用户注册功能")是把 JSP 从"又画页面又写逻辑"的 Model1 里解放出来:JSP 只做 View,Servlet 或框架控制器做路由,JavaBean 或 Service 做业务。放到 SSM 里,这个分工更明确——SpringMVC 的 DispatcherServlet 就是总 Servlet,Controller 替代了手写 Servlet 的 doGet/doPost,Service 是业务层,MyBatis 的 Mapper 是数据访问层,JSP 退回到纯粹的模板角色。
很多新手在 SSM 项目里写 JSP 时仍然忍不住这样:
<% User user = (User) request.getSession().getAttribute("user"); if (user != null && user.getRole().equals("admin")) { %>这是典型的 Model1 写法,问题在于 JSP 里出现了 Java 类型强转和字符串比较。一旦业务变多,页面里的逻辑会迅速失控。正确的姿势是让 Controller 把数据塞进 Model,JSP 只用 EL 表达式取:
<c:if test="${sessionScope.user != null && sessionScope.user.role == 'admin'}"> <a href="${pageContext.request.contextPath}/admin/lab/add">新增实验室</a> </c:if>EL 里可以直接用 == 比较字符串,因为 EL 底层调用的是 equals。这段代码看起来只是写法差别,实际上决定了 JSP 文件能不能在页面展示和业务逻辑之间干净分工。在 SSM 项目里,JSP 页面中的 Java 小脚本(<% %>)应当为零,这是第一道红线,也是判断一个人是真会 SSM 还是只会套模板的试金石。
2.2 实验室管理系统的功能边界与数据库表设计
做实验室管理系统前先圈功能。学生或教师登录后能查看实验室列表、设备清单,提交预约申请;管理员审核预约、维护实验室和设备信息、发布公告;系统要能检测同一时段同一实验室的预约冲突。这是最精简的闭环,再加统计报表就是锦上添花。核心就这五张表:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| user | id, username, password, real_name, role, student_no, phone, email | role 用字符串枚举:admin / teacher / student |
| lab | id, lab_name, location, capacity, status, equipment_desc | status 控制实验室是否开放预约 |
| equipment | id, lab_id, name, model, quantity, status | 挂在实验室下的设备台账 |
| reservation | id, user_id, lab_id, purpose, reserve_date, start_time, end_time, status, admin_id, remark | status:0 待审核,1 已通过,2 已驳回,3 已结束 |
| announcement | id, title, content, publisher_id, created_at | 公告,管理员发布 |
reservation 表是业务核心。设计时一定要用 reserve_date + start_time + end_time 的组合,而不是单独存一个 datetime 字段,因为审核员需要按天查看排班、按时间段筛选冲突。status 字段不要用布尔值,预约状态至少有"待审核 / 通过 / 驳回 / 结束"四种,布尔只能表达两种,后期扩展状态机会很少,改表结构又麻烦。
表设计阶段把关联查询路径想清楚,后面写 Mapper 会轻松很多。拿预约列表页举例,管理员要看到的不只是预约记录,还有申请人姓名、实验室名称、设备数量,这三条信息分别来自 user、lab、equipment。我一般在 Mapper 的关联查询里直接 JOIN,而不是在 Service 里循环查库——循环查库是典型性能隐患,数据量小没感觉,数据量过千就明显卡顿。外键我只在关联查询频繁的列上建普通索引,不建物理外键,物理外键在删除用户时经常给自己找麻烦,靠 SQL 层控制足够。
2.3 Maven 工程结构:一个 war 包该有哪些目录与配置文件
用 IDEA 新建 JSP 项目有两条路:直接选 Java Enterprise 模板勾选 Web Application,或者用 Maven 骨架 maven-archetype-webapp。后者更干净,因为最终我们要的是能执行 mvn package 打出 war 包的结构:
lab-manager/ ├── pom.xml ├── src/main/java/com/example/lab/ │ ├── controller/ │ ├── service/ + service/impl/ │ ├── dao/(Mapper 接口) │ ├── entity/(或 model/) │ └── common/(分页结果、统一返回) ├── src/main/resources/ │ ├── jdbc.properties │ ├── spring-mvc.xml │ ├── spring-mybatis.xml │ ├── mybatis-config.xml │ └── mapper/(ReservationMapper.xml 等) └── src/main/webapp/ ├── WEB-INF/web.xml ├── WEB-INF/views/(JSP 放这里,外部不能直接访问) ├── static/(css/js/images) └── index.jsp注意 JSP 放 WEB-INF/views 而不是 webapp 根目录,这是安全习惯:WEB-INF 下的文件不能通过浏览器 URL 直接访问,必须经过 Controller 转发,从源头堵住"用户猜路径、绕过登录看页面"的漏洞。static 目录放静态资源,则是因为要配合 SpringMVC 的静态资源放行配置,第 3 章会具体说。
这个结构里最容易忽略的是 resources 下的 mapper 目录。MyBatis 的 XML 文件放在 resources/mapper 下,与 dao 接口通过 namespace 绑定,而不是强行和 Java 类放一起。这样做的好处是 XML 会被 Maven 自动打进 classes 目录,运行时 classpath 能扫到。很多新手把 Mapper.xml 放在 src/main/java 下又忘了在 pom.xml 里配置 resources 过滤,结果启动时报 Invalid bound statement (not found),这是 SSM 项目里最常见的黑匣子错误之一。一个 war 包项目的配置骨架就这些:Spring 容器负责管理 Service、Mapper、事务,SpringMVC 容器负责管理 Controller 和视图解析,两者通过 web.xml 在 Tomcat 启动时初始化。层级结构先立住,后面代码才不会写成一坨。
3. 把后端跑通:SSM 框架整合的最小可用配置
3.1 依赖与 web.xml:让 Spring 容器在 Tomcat 里先活起来
pom.xml 核心依赖是这样一组:
<!-- Spring 5.x --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.x</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.x</version> </dependency> <!-- MyBatis 3.x 与 Spring 整合包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.x</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.x</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.x</version> </dependency> <!-- Druid 连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.x</version> </dependency> <!-- JSTL,JSP 页面里 c:forEach 全靠它 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>servlet-api 的 scope 必须设 provided,因为 Tomcat 自己带了一份 Servlet 实现,war 包里重复带会引发类加载冲突。JSTL 1.2 是老项目最常用的版本,和 JSP 2.x 配合稳定。mybatis-spring 2.0.x 对应 MyBatis 3.5.x,版本要对上,否则 Mapper 扫描会静默失败。
web.xml 的任务是启动 Spring 容器和 SpringMVC 的 DispatcherServlet:
<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mybatis.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <filter> <filter-name>encodingFilter</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>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <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>这段配置里 forceEncoding 是关键,它保证 POST 请求体的编码也被强制成 UTF-8。只设 encoding 在某些 Tomcat 版本下 GET 和 POST 会各用各的编码,中文参数经常在某个环节突然变问号。DispatcherServlet 的 url-pattern 用 / 而不是 /,/ 会交给 SpringMVC 处理并允许它找到 JSP,/会拦截 JSP 本身的请求导致页面渲染不出来。这个坑几乎每个 SSM 新手都踩过,属于一套组合拳里最容易翻车的第一环。
3.2 Spring 与 MyBatis 整合:连接池、事务、Mapper 扫描
spring-mybatis.xml 是这个项目的数据访问中枢:
<context:component-scan base-package="com.example.lab.service.impl"/> <context:property-placeholder location="classpath:jdbc.properties"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> <property name="minIdle" value="5"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.example.lab.entity"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.lab.dao"/> </bean> <bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="txManager"/>这里 4 个参数值得盯:initialSize 是启动时建立的连接数,小系统 5 个足够;maxActive 是最大连接数,20 对实验室这种并发量绰绰有余,开太大反而占 MySQL 的连接数上限;minIdle 保持 5 个空闲连接,避免高峰期临时建连接的抖动;druid 的 init-method="init" 是连接池启动的触发点,漏掉的话连接池会懒初始化,第一次请求可能卡上几秒。
事务注解放在 service.impl 包上,所以这里 component-scan 只扫 service.impl。MapperScannerConfigurer 把 com.example.lab.dao 下所有接口扫描成 Spring 代理 Bean,Controller 和 Service 里可以直接 @Autowired 一个 Mapper 接口,不用自己写实现类。mybatis-config.xml 里我一般只放一个驼峰映射开关:
<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> </configuration>开了这个开关,数据库里的 reserve_date 才能自动映射到实体属性 reserveDate,否则 MyBatis 默认把两者当成不同字段,查出来的对象一堆 null。
3.3 SpringMVC 配置:视图解析器、静态资源放行、JSON 响应
spring-mvc.xml 负责 Web 层:
<context:component-scan base-package="com.example.lab.controller"/> <mvc:annotation-driven/> <mvc:resources mapping="/static/**" location="/static/"/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>component-scan 只扫 controller,和 spring-mybatis.xml 扫 service.impl 严格分开。如果这里手滑把 base-package 写成 com.example.lab 全家桶,Service 会被 SpringMVC 容器再扫一遍,tx:annotation-driven 所在容器生成的代理就失去作用,事务会静默失效——这是事务翻车的头号原因,后面避坑章专门说。
InternalResourceViewResolver 的 prefix 指向 WEB-INF/views,Controller 里 return "admin/reservationList",实际解析到 /WEB-INF/views/admin/reservationList.jsp。JSP 的物理路径被藏起来,也算一层防护。mvc:annotation-driven 负责注册 @Controller 的映射,以及 @ResponseBody 的 JSON 转换器。
3.4 最小业务闭环:学生提交预约的完整链路
Controller 入口:
@Controller @RequestMapping("/reservation") public class ReservationController { @Autowired private ReservationService reservationService; @PostMapping("/apply") public String apply(Reservation reservation, HttpSession session) { User user = (User) session.getAttribute("user"); if (user == null) { return "redirect:/login"; } reservation.setUserId(user.getId()); reservation.setStatus(0); try { reservationService.applyReservation(reservation); } catch (ConflictException e) { return "redirect:/reservation/applyPage?error=conflict"; } return "redirect:/reservation/myList"; } }Service 实现类:
@Service public class ReservationServiceImpl implements ReservationService { @Autowired private ReservationMapper reservationMapper; @Transactional(rollbackFor = Exception.class) @Override public void applyReservation(Reservation reservation) { int conflict = reservationMapper.countConflict( reservation.getLabId(), reservation.getReserveDate(), reservation.getStartTime(), reservation.getEndTime()); if (conflict > 0) { throw new ConflictException("该时段实验室已被预约"); } reservationMapper.insert(reservation); } }Mapper XML 里最关键的是一段查冲突的 SQL:
<select id="countConflict" resultType="int"> SELECT COUNT(*) FROM reservation WHERE lab_id = #{labId} AND reserve_date = #{reserveDate} AND status = 1 AND ( (#{startTime} BETWEEN start_time AND end_time) OR (#{endTime} BETWEEN start_time AND end_time) OR (start_time BETWEEN #{startTime} AND #{endTime}) ) </select>冲突检测的边界条件是这套系统最容易出 bug 的地方。用 start <= newEnd AND end >= newStart 也能写,但 BETWEEN 的三种组合更直观:新时间段落在旧时间段内、旧时间段落在新时间段内、旧时间段被新时间段完整包住。这里排除了 status = 1(已通过)之外的预约,避免和已驳回或待审核的记录冲突。业务校验放在 Service 里配合 @Transactional,插入和检查要么一起成功要么一起回滚。注意 rollbackFor = Exception.class 必写,Spring 默认只对 RuntimeException 回滚,而自定义的 ConflictException 如果继承 Exception,不加这个参数就不会触发回滚。
4. JSP 页面与前端交互:从登录页到预约审批的完整链路
4.1 用 EL 和 JSTL 改造页面:别再让 JSP 变 Java 垃圾场
后端把数据放进 Model 后,JSP 页面的任务就是把它渲染出来。先看一个预约列表页的核心片段:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <html> <body> <table class="table table-hover"> <c:forEach items="${pageInfo.list}" var="r" varStatus="vs"> <tr> <td>${vs.count}</td> <td>${r.labName}</td> <td><fmt:formatDate value="${r.reserveDate}" pattern="yyyy-MM-dd"/></td> <td>${r.startTime} - ${r.endTime}</td> <td> <c:choose> <c:when test="${r.status == 0}">待审核</c:when> <c:when test="${r.status == 1}">已通过</c:when> <c:when test="${r.status == 2}">已驳回</c:when> <c:otherwise>已结束</c:otherwise> </c:choose> </td> </tr> </c:forEach> </table> </body> </html>varStatus 的 count 属性是行号,从 1 开始,比自己在循环里维护计数器省事。c:choose 比多个 c:if 更适合这种多状态枚举,因为它在匹配到第一个 when 后就不再判断后面的分支,少做几次字符串比较,更重要的是语义清晰。fmt:formatDate 必须配合 java.util.Date 使用,如果实体里存的是 java.time.LocalDate,这里会直接报错——这是 JSP 老技术栈和 Java 8 时间类型最常见的冲突,规避办法是在实体里继续用 java.util.Date,或者写好转换器再上 LocalDate。
页面里所有资源引用都要带着 ${pageContext.request.contextPath},这是当前应用部署路径,war 包名改了它也跟着变,不写的话一旦部署路径不是根路径,css 和 js 全部 404。
4.2 个人信息展示页面:查询当前用户与本人预约记录
"jsp 个人信息展示页面"是很多人的必做项,逻辑不复杂但最容易写出安全漏洞。先看 Controller:
@GetMapping("/myInfo") public String myInfo(HttpSession session, Model model) { User user = (User) session.getAttribute("user"); if (user == null) { return "redirect:/login"; } List<ReservationVO> list = reservationMapper.selectByUserId(user.getId()); model.addAttribute("reservationList", list); return "user/myInfo"; }对应 JSP 里展示头像、真实姓名、学号、角色、邮箱,下面挂一个本人的预约记录表格。注意这里从 session 取 user,而不是从数据库再查一次,省一次 IO;但 user 对象里绝不能有密码字段,更不要把密码渲染到页面上。很多毕设把整个 User 对象塞进 session,页面里还顺手 ${user.password},这是给自己埋雷——答辩老师只要打开浏览器开发者工具看一眼 cookie 就能抓到这种低级问题。
个人信息页通常还带修改密码功能。修改密码不能直接 update 一个新字符串,至少要校验原密码:
@PostMapping("/changePwd") public String changePwd(String oldPwd, String newPwd, HttpSession session) { User user = (User) session.getAttribute("user"); if (!user.getPassword().equals(oldPwd)) { return "redirect:/myInfo?error=oldPwdWrong"; } userService.updatePassword(user.getId(), newPwd); session.invalidate(); return "redirect:/login"; }改完密码要把 session 失效,强制重新登录,这是个容易被忽略的细节。
4.3 分页与模糊搜索:PageHelper 在 JSP 层怎么配合
列表页不可能一次性把所有预约记录查出来,实验室管理系统里按日期筛选、按实验室名称搜索是刚需。常见做法是 PageHelper 加 mapper 里的模糊查询:
@RequestMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, String keyword, Model model) { PageHelper.startPage(pageNum, pageSize); List<ReservationVO> list = reservationMapper.selectReservationList(keyword); PageInfo<ReservationVO> pageInfo = new PageInfo<>(list); model.addAttribute("pageInfo", pageInfo); return "admin/reservationList"; }Mapper XML 里的模糊查询:
<select id="selectReservationList" resultType="com.example.lab.entity.ReservationVO"> SELECT r.*, u.real_name AS userName, l.lab_name AS labName FROM reservation r JOIN user u ON r.user_id = u.id JOIN lab l ON r.lab_id = l.id <where> <if test="keyword != null and keyword != ''"> u.real_name LIKE CONCAT('%', #{keyword}, '%') OR l.lab_name LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY r.reserve_date DESC, r.start_time DESC </select>PageHelper.startPage 只对紧接着的一次查询生效,下一行必须直接是 Mapper 调用,中间不能穿插任何其他 SQL 操作或日志查询,否则分页数据就错位。PageInfo 里封装了 pageNum、pageSize、total、pages、list 五个常用属性,JSP 分页条直接取值渲染:
<c:if test="${pageInfo.pageNum > 1}"> <a href="?pageNum=${pageInfo.pageNum - 1}&keyword=${keyword}">上一页</a> </c:if> <span>${pageInfo.pageNum} / ${pageInfo.pages}</span> <c:if test="${pageInfo.pageNum < pageInfo.pages}"> <a href="?pageNum=${pageInfo.pageNum + 1}&keyword=${keyword}">下一页</a> </c:if>这里 keyword 要从 Controller 的 model 里回传,否则翻页后搜索条件就丢了。这是分页功能最常见的体验 bug,没有之一。
4.4 表单提交与 Ajax 联动:预约时间段选择的常见做法
预约表单通常要选实验室、选日期、选时间段。最简单可靠的做法是三级联动:实验室下拉框 → 日期选择 → 时间段 Ajax 加载可用时段。
前端用 jQuery 发 Ajax:
$("#labSelect").change(function () { var labId = $(this).val(); var date = $("#reserveDate").val(); if (!labId || !date) return; $.post(contextPath + "/reservation/availableTime", { labId: labId, date: date }, function (data) { var html = ""; data.forEach(function (slot) { html += "<option value='" + slot + "'>" + slot + "</option>"; }); $("#timeSlot").html(html); }); });后端接口:
@ResponseBody @PostMapping("/availableTime") public List<String> availableTime(Integer labId, String date) { return reservationService.getAvailableTimeSlots(labId, date); }@ResponseBody 配合 spring-mvc.xml 里的 mvc:annotation-driven,自动用 Jackson 把返回对象转成 JSON。如果返回值里带 java.util.Date,Jackson 默认序列化成时间戳数字,前端拿到的是 1728000000 这种没人看得懂的东西。规避办法是让接口直接返回格式化好的字符串、字符串 List,或者统一配置自定义 ObjectMapper。这块属于 SSM 联调时最常见的"黑匣子"——后端返回没问题,前端就是显示不对,最后发现是日期序列化格式的问题。
Ajax 请求路径同样要带 contextPath,SpringMVC 的 @RequestMapping 只匹配项目内路径,绝对路径里不带项目名就会 404。
5. SSM + JSP 避坑实录:五条血泪经验
5.1 现象:事务明明加了注解,数据却照常插入
现象:Service 方法上有 @Transactional(rollbackFor = Exception.class),方法中途抛出异常,但数据库里该插入的数据还是存在,事务完全没生效。
原因:spring-mvc.xml 里的 component-scan 把 base-package 写成了 com.example.lab,连 service.impl 也被 SpringMVC 容器扫描并创建了一遍。事务代理是 spring-mybatis.xml 里的 tx:annotation-driven 基于 Spring 容器生成的,而 Controller 用的是 SpringMVC 容器里的 Service,这个 Service 没有事务代理,注解形同虚设。
解决:两个配置文件的扫描包严格切分。spring-mvc.xml 只写 base-package="com.example.lab.controller",spring-mybatis.xml 只写 base-package="com.example.lab.service.impl"。排查时先看 Tomcat 启动日志里是否有"overriding bean definition"这类警告,看到就基本坐实了重复扫描。
5.2 现象:JSP 页面能打开,css 和 js 全部 404
现象:登录页是出来了,但完全没有样式,浏览器调试台里 app.css、jquery.js 全部报 404。
原因:DispatcherServlet 的 url-pattern 配置成 /,把所有静态文件请求都拦截了,SpringMVC 没有对应的 Handler 处理 .css 和 .js。
解决:spring-mvc.xml 里加静态资源放行:
<mvc:resources mapping="/static/**" location="/static/"/>同时页面里的资源路径要写对:${pageContext.request.contextPath}/static/css/app.css。如果项目没有把静态文件放进 static 目录而是散落在 webapp 根目录,那 mapping 就要对应调整,比如 mapping="/css/**" location="/css/",但这种散落布局不推荐,因为和 JSP 文件混在一起容易被直接访问。
5.3 现象:数据库日期字段有值,JSP 页面渲染出来却是 null 或乱码
现象:reserve_date 在数据库里明明是 2025-06-10,页面上显示空白;中文名称全部显示成问号。
原因:两个问题叠加。日期为空是因为 MyBatis 默认关闭驼峰映射,实体属性 reserveDate 和数据库列 reserve_date 对不上,查出来是 null。中文乱码是因为 CharacterEncodingFilter 没配 forceEncoding=true,或者 JSP 页面头部的 contentType 漏了 charset=UTF-8。
解决:mybatis-config.xml 里加一行:
<setting name="mapUnderscoreToCamelCase" value="true"/>JSP 页面第一行写全:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>web.xml 里的过滤器 forceEncoding 设 true。这三处都做了,乱码和日期 null 基本同时消失。
5.4 现象:IDEA 新建 JSP 项目,连 web.xml 都没有
现象:用 Maven 骨架创建完项目,想在 src/main/webapp 下补一个 web.xml,右键发现 New 菜单里根本没有 Web 选项,直接跑 Tomcat 报 Artifact 'xxx' 缺少 Web 方面。
原因:IDEA 的 Maven 骨架项目默认不启用 Web Facet,只有 Web 目录和 web.xml 都具备,IDEA 才认为这是一个合法的 Web 工程。
解决:Project Structure → Facets → 点 + 号 → Web,然后指定 Web Resource Directory 为 src/main/webapp,勾选 Create web.xml。更省事的做法是初始化时直接用 Maven 骨架 maven-archetype-webapp:
mvn archetype:generate -DgroupId=com.example -DartifactId=lab-manager \ -DarchetypeArtifactId=maven-archetype-webapp -DinteractiveMode=false这个骨架自带 src/main/webapp/WEB-INF/web.xml,省掉手动补 Facet 的麻烦。传统 JSP 项目打包 war 之前,先把这一步确认好,否则后面所有配置都没地方放。
5.5 现象:nginx 转发后 JSP 变成下载文件或直接 404
现象:把 war 包解压后的目录直接放进 nginx 的 html 目录,浏览器访问 login.jsp 要么弹出下载框,要么 404;配置了 proxy_pass 转发到 Tomcat 后,JSP 又渲染成纯文本。
原因:nginx 本身不支持 JSP。nginx 是静态文件服务器和反向代理,它不认识 JSP,不知道 .jsp 文件需要先经过 JSP 引擎编译成 Servlet 再执行。JSP 的编译和运行是 Tomcat 这类 Servlet 容器的职责,nginx 拿到 .jsp 文件只能当普通文本或二进制文件处理。
解决:标准做法是 nginx 只做反向代理,动态请求全部交给 Tomcat:
server { listen 80; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /opt/tomcat/webapps/lab-manager/static/; } }static 的 location 规则要写在 / 之前,nginx 匹配 location 时按前缀优先,静态请求先命中 /static/,动态请求落到 / 转发给 Tomcat。所以"Nginx 支持 jsp 吗"这个问题的答案是否定的,但通过 Tomcat 协作,JSP 项目完全可以跑在 nginx 后面。
6. 打包部署与进阶验收:war 包交付前必须过的手工检查
6.1 从 mvn package 到 Tomcat webapps:传统项目的部署路径
传统 JSP 项目最终交付物就是一个 war 包。打包部署这条链路我建议从头到尾敲一遍命令,别只依赖 IDEA 的绿色三角按钮:
mvn clean package ls target/lab-manager.war cp target/lab-manager.war /opt/tomcat/webapps/ /opt/tomcat/bin/startup.shwar 包名决定访问路径:lab-manager.war 对应 http://ip:8080/lab-manager/,想换路径直接改 war 包名。Tomcat 会自动解压,但如果之前解压过同名目录,记得先清掉,否则会留下旧的 class 文件,改代码后页面表现不更新,白白浪费半小时。数据库连接配置别写在代码里,用 jdbc.properties,部署时改 properties 不用重新打包。
6.2 预约冲突与状态流转:两组测试用例验收核心业务
验收这套系统,我只看两个用例。用例一:用 student 账号预约实验室 A 周二 14:00-16:00,再用另一个 student 账号预约同一实验室同时段,后端必须拒绝并给出冲突提示。用例二:管理员驳回其中一条预约后,被占用的时段应立即释放,再用 student 账号预约同一时段应该成功。
第二个用例能暴露 countConflict 的 SQL 是否漏了 status = 1 条件。如果漏了,已驳回的记录会被当成占用,管理员永远无法重新排期,系统相当于卡死。很多项目第一用例能过,第二用例过不了,问题就出在这一个状态过滤条件上。状态流转还要手工验证:待审核 → 通过 → 结束,以及待审核 → 驳回。每个流转节点的列表页状态显示要对得上,c:choose 里漏一个分支,页面就会出现空白格。
6.3 后续演进:这套 SSM + JSP 代码怎么低成本迁到 Spring Boot
老项目迟早要面对 Spring Boot 化的诱惑。Spring Boot 2 集成 JSP 有三个硬性条件:页面放在 src/main/webapp/WEB-INF/jsp 目录不是 WEB-INF/views;依赖里加 tomcat-embed-jasper;application.yml 里配好视图解析器:
spring: mvc: view: prefix: /WEB-INF/jsp/ suffix: .jsp迁移时 Mapper 接口、Mapper XML、Service 几乎可以原样搬,Controller 里的注解也不用改。实际上制约迁移的不是代码,而是 JSP 这个依赖外部 Servlet 容器的技术选型本身——Spring Boot 默认打成可执行 jar,内嵌 Tomcat 对 JSP 支持有限,所以通常还是打成 war 包部署。我做过几次这类迁移,最大的教训是别一口吃成胖子,按模块迁,先把公告模块迁过去跑通,再逐步换掉高频 JSP 页面。
这么多年做下来我养成了一个习惯:任何 SSM 项目交付前,先自己扮演管理员走一遍完整的"预约 → 审核 → 使用 → 结束"流程,再扮演学生试一次冲突提交。这两个角色各跑一遍,数据库里插入、更新、联表查询全部过手,比看十遍代码都管用。希望帮到你。
本文还有配套的精品资源,点击获取