SSM + JSP 实验室管理系统开发实战:架构、避坑与部署
发布时间:2026/9/26 12:39:44来源:尧图网络
简介面向实验室管理场景的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 加上预约流转。它解决的是实验室管理里最头疼的三件事——谁在用、什么时候用、设备还在不在。技术栈选 SSMSpring 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 只做 ViewServlet 或框架控制器做路由JavaBean 或 Service 做业务。放到 SSM 里这个分工更明确——SpringMVC 的 DispatcherServlet 就是总 ServletController 替代了手写 Servlet 的 doGet/doPostService 是业务层MyBatis 的 Mapper 是数据访问层JSP 退回到纯粹的模板角色。很多新手在 SSM 项目里写 JSP 时仍然忍不住这样% User user (User) request.getSession().getAttribute(user); if (user ! null user.getRole().equals(admin)) { %这是典型的 Model1 写法问题在于 JSP 里出现了 Java 类型强转和字符串比较。一旦业务变多页面里的逻辑会迅速失控。正确的姿势是让 Controller 把数据塞进 ModelJSP 只用 EL 表达式取c:if test${sessionScope.user ! null sessionScope.user.role admin} a href${pageContext.request.contextPath}/admin/lab/add新增实验室/a /c:ifEL 里可以直接用 比较字符串因为 EL 底层调用的是 equals。这段代码看起来只是写法差别实际上决定了 JSP 文件能不能在页面展示和业务逻辑之间干净分工。在 SSM 项目里JSP 页面中的 Java 小脚本% %应当为零这是第一道红线也是判断一个人是真会 SSM 还是只会套模板的试金石。2.2 实验室管理系统的功能边界与数据库表设计做实验室管理系统前先圈功能。学生或教师登录后能查看实验室列表、设备清单提交预约申请管理员审核预约、维护实验室和设备信息、发布公告系统要能检测同一时段同一实验室的预约冲突。这是最精简的闭环再加统计报表就是锦上添花。核心就这五张表表名关键字段说明userid, username, password, real_name, role, student_no, phone, emailrole 用字符串枚举admin / teacher / studentlabid, lab_name, location, capacity, status, equipment_descstatus 控制实验室是否开放预约equipmentid, lab_id, name, model, quantity, status挂在实验室下的设备台账reservationid, user_id, lab_id, purpose, reserve_date, start_time, end_time, status, admin_id, remarkstatus0 待审核1 已通过2 已驳回3 已结束announcementid, 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 groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.x/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.3.x/version /dependency !-- MyBatis 3.x 与 Spring 整合包 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.x/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.x/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.x/version /dependency !-- Druid 连接池 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.x/version /dependency !-- JSTLJSP 页面里 c:forEach 全靠它 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependencyservlet-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 的 DispatcherServletcontext-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mybatis.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/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-packagecom.example.lab.service.impl/ context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property nameinitialSize value5/ property namemaxActive value20/ property nameminIdle value5/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.example.lab.entity/ property nameconfigLocation valueclasspath:mybatis-config.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.lab.dao/ /bean bean idtxManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertxManager/这里 4 个参数值得盯initialSize 是启动时建立的连接数小系统 5 个足够maxActive 是最大连接数20 对实验室这种并发量绰绰有余开太大反而占 MySQL 的连接数上限minIdle 保持 5 个空闲连接避免高峰期临时建连接的抖动druid 的 init-methodinit 是连接池启动的触发点漏掉的话连接池会懒初始化第一次请求可能卡上几秒。事务注解放在 service.impl 包上所以这里 component-scan 只扫 service.impl。MapperScannerConfigurer 把 com.example.lab.dao 下所有接口扫描成 Spring 代理 BeanController 和 Service 里可以直接 Autowired 一个 Mapper 接口不用自己写实现类。mybatis-config.xml 里我一般只放一个驼峰映射开关configuration settings setting namemapUnderscoreToCamelCase valuetrue/ /settings /configuration开了这个开关数据库里的 reserve_date 才能自动映射到实体属性 reserveDate否则 MyBatis 默认把两者当成不同字段查出来的对象一堆 null。3.3 SpringMVC 配置视图解析器、静态资源放行、JSON 响应spring-mvc.xml 负责 Web 层context:component-scan base-packagecom.example.lab.controller/ mvc:annotation-driven/ mvc:resources mapping/static/** location/static// bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /beancomponent-scan 只扫 controller和 spring-mybatis.xml 扫 service.impl 严格分开。如果这里手滑把 base-package 写成 com.example.lab 全家桶Service 会被 SpringMVC 容器再扫一遍tx:annotation-driven 所在容器生成的代理就失去作用事务会静默失效——这是事务翻车的头号原因后面避坑章专门说。InternalResourceViewResolver 的 prefix 指向 WEB-INF/viewsController 里 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?errorconflict; } 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 里最关键的是一段查冲突的 SQLselect idcountConflict resultTypeint 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 contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % html body table classtable table-hover c:forEach items${pageInfo.list} varr varStatusvs tr td${vs.count}/td td${r.labName}/td tdfmt:formatDate value${r.reserveDate} patternyyyy-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 /htmlvarStatus 的 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 个人信息展示页面是很多人的必做项逻辑不复杂但最容易写出安全漏洞。先看 ControllerGetMapping(/myInfo) public String myInfo(HttpSession session, Model model) { User user (User) session.getAttribute(user); if (user null) { return redirect:/login; } ListReservationVO 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?erroroldPwdWrong; } 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); ListReservationVO list reservationMapper.selectReservationList(keyword); PageInfoReservationVO pageInfo new PageInfo(list); model.addAttribute(pageInfo, pageInfo); return admin/reservationList; }Mapper XML 里的模糊查询select idselectReservationList resultTypecom.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 testkeyword ! 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 /selectPageHelper.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 ListString availableTime(Integer labId, String date) { return reservationService.getAvailableTimeSlots(labId, date); }ResponseBody 配合 spring-mvc.xml 里的 mvc:annotation-driven自动用 Jackson 把返回对象转成 JSON。如果返回值里带 java.util.DateJackson 默认序列化成时间戳数字前端拿到的是 1728000000 这种没人看得懂的东西。规避办法是让接口直接返回格式化好的字符串、字符串 List或者统一配置自定义 ObjectMapper。这块属于 SSM 联调时最常见的黑匣子——后端返回没问题前端就是显示不对最后发现是日期序列化格式的问题。Ajax 请求路径同样要带 contextPathSpringMVC 的 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-packagecom.example.lab.controllerspring-mybatis.xml 只写 base-packagecom.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 没配 forceEncodingtrue或者 JSP 页面头部的 contentType 漏了 charsetUTF-8。解决mybatis-config.xml 里加一行setting namemapUnderscoreToCamelCase valuetrue/JSP 页面第一行写全% page contentTypetext/html;charsetUTF-8 languagejava %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-webappmvn archetype:generate -DgroupIdcom.example -DartifactIdlab-manager \ -DarchetypeArtifactIdmaven-archetype-webapp -DinteractiveModefalse这个骨架自带 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 只做反向代理动态请求全部交给 Tomcatserver { 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-jasperapplication.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 项目交付前先自己扮演管理员走一遍完整的预约 → 审核 → 使用 → 结束流程再扮演学生试一次冲突提交。这两个角色各跑一遍数据库里插入、更新、联表查询全部过手比看十遍代码都管用。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网