简介:这套Java毕业设计资源围绕酒店客房预定管理系统展开,基于SSM框架与JSP技术,适合计算机相关专业本科生或专科生完成毕业设计、课程设计时参考,也可作为Java Web开发入门者的练习项目。系统按管理员和用户两类角色划分,管理员可处理个人中心、用户管理、客房类型、酒店客房、客房预定、开房记录、退房结账及系统公告等业务,用户则能浏览首页、在线查看客房、管理个人中心,整体业务链完整。资源包共1253个文件,以Java源码、JSP页面、CSS样式、JavaScript脚本为主,包含SQL建库脚本与说明文档,压缩包大小18.15MB,目录结构清晰,便于按模块查阅。附带的说明文档和LW文档可辅助撰写毕业论文,PPT可配合答辩演示,帮助读者快速理解设计思路与实现细节。目前已有208人学习下载,适合需要快速搭建同类型信息管理系统的开发者借鉴复用。
1. 酒店客房预定管理系统:毕业设计里最被低估的「稳」选题
每年毕业设计季,Java Web 方向的选题池里总有几个高频面孔:电商、论坛、博客,还有酒店客房预定管理系统。很多人嫌弃它太普通,扭头去选更有排面的,结果需求越滚越大,答辩前一周还在补 Bug。这个选题是典型的"小而不薄":业务链条完整,每一环又不至于失控。
所谓 SSM + JSP,就是 Spring、SpringMVC、MyBatis 加 JSP 视图的经典组合,恰好配套完整源码、说明文档、LW 论文和答辩 PPT 四件套。客户选房、下单预定、校验可订、生成订单、入住退房、统计营业额,这套闭环正好覆盖状态流转、日期冲突和聚合统计这些答辩高频考点。
照抄代码能过查重,过不了答辩提问,这篇按一线做法把系统拆通透。
2. SSM + JSP 架构拆解:这套「老组合」为什么还是毕业设计主力
2.1 三层架构与 MVC 的对应关系:先分清谁干什么
拿到压缩包的第一件事,不是急着解压跑代码,而是先看懂分层。SSM 项目本质上是经典三层架构加 MVC 思想的结合,很多同学答辩时被问"你的项目架构是什么"就卡住,是因为从来没把这两套概念对到自己的代码上。
表现层由 JSP 页面和 SpringMVC 的 Controller 共同构成。JSP 负责渲染数据和收集输入,Controller 负责接收请求参数、调用业务层、决定页面跳转,对应 MVC 里的 View 和 Controller。业务层是 Service 接口加实现类,处理预定时的时间冲突判断、订单状态流转、价格计算这类规则。持久层是 MyBatis 的 Mapper 接口和 XML 映射文件,只负责和 MySQL 打交道。
| 层次 | 技术组件 | 核心职责 | MVC 对应 |
|---|---|---|---|
| 表现层 | JSP、Controller | 参数接收、页面跳转、简单校验 | View + Controller |
| 业务层 | Service 接口 + 实现 | 业务规则、事务边界 | Model |
| 持久层 | Mapper 接口 + XML | SQL 执行、结果映射 | Model |
我见过太多人在 Service 里写 SQL、在 Controller 里堆业务判断,项目能跑,但被问一句"订单状态要加一种,你得改几个文件"就答不上来。分层清晰的项目加状态,只需要改数据库字典和 Service 判断,其他层几乎不动。这也是为什么我拿到任何 SSM 源码,第一步永远是画目录树和分层图,而不是直接启动 Tomcat。
2.2 拿到源码第一件事:把这个目录结构印进脑子里
一个标准的 SSM + JSP 酒店客房预定系统,目录骨架八九不离十是这样,包名可能不同,职责划分是同一套:
hotel-ssm/ ├── pom.xml ├── sql/ │ └── hotel_db.sql ├── src/main/java/ │ ├── com/hotel/controller/ # Controller 层 │ │ ├── RoomController.java │ │ ├── ReservationController.java │ │ └── CustomerController.java │ ├── com/hotel/service/ # 业务层接口与实现 │ │ ├── ReservationService.java │ │ └── impl/ReservationServiceImpl.java │ ├── com/hotel/mapper/ # MyBatis Mapper 接口 │ │ ├── RoomMapper.java │ │ └── ReservationMapper.java │ ├── com/hotel/entity/ # 实体类 │ │ ├── Room.java │ │ ├── Reservation.java │ │ └── Customer.java │ └── com/hotel/util/ # 工具类 ├── src/main/resources/ │ ├── jdbc.properties │ ├── spring-mybatis.xml │ ├── spring-mvc.xml │ └── mapper/ # MyBatis XML 映射 │ ├── RoomMapper.xml │ └── ReservationMapper.xml └── src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ # JSP 页面 │ ├── index.jsp │ ├── room/ │ └── reservation/ ├── static/ # css / js / 图片 └── uploads/ # 房间图片上传目录两个新手最容易忽略的点。第一,src/main/resources/mapper/下的 XML 和com/hotel/mapper/下的接口是成对出现的,MyBatis 要求 XML 的 namespace 等于接口全限定名,否则启动后第一次调用就报BindingException。第二,WEB-INF/views/下的 JSP 不能通过 URL 直接访问,必须由 Controller 转发进入,这是安全设计,答辩时主动讲出来是加分项。
sql/hotel_db.sql是整个系统的地基。我的习惯是拿到项目先执行脚本建库,再回头看代码,因为很多业务逻辑不看表结构根本读不懂。比如订单状态字段是用TINYINT存数字还是VARCHAR存字符串,直接决定 Service 层的写法。
2.3 SSM 整合的关键:三份配置文件各管一摊
SSM 整合的核心是搞清楚 Spring 根容器和 SpringMVC 子容器谁管谁。常见配置是两份 Spring XML 加一份web.xml:spring-mybatis.xml管数据源、事务、Mapper 扫描和 Service;spring-mvc.xml管 Controller 扫描、视图解析器和静态资源放行。
<!-- 1. 读取数据库连接配置 --> <context:property-placeholder location="classpath:jdbc.properties"/> <!-- 2. 数据源 --> <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <!-- 3. SqlSessionFactory,注册 Mapper XML 位置 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <!-- 4. 扫描 Mapper 接口,生成代理 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.hotel.mapper"/> </bean> <!-- 5. 事务管理器与注解事务开关 --> <bean id="dataSourceTransactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="dataSourceTransactionManager"/> <!-- 6. 扫描 Service,放入根容器 --> <context:component-scan base-package="com.hotel.service"/>参数说明:
mapperLocations决定 MyBatis 去哪个目录找 XML,路径写错启动不报错,第一次调用 Mapper 方法才抛Invalid bound statement,属于延迟爆雷。MapperScannerConfigurer扫描接口包,把接口和 XML 通过 namespace 绑定后生成代理对象,注入到 Service 里。<tx:annotation-driven>是事务总开关,漏了它,Service 上的@Transactional全部静默失效,后面专门讲这个坑。
再看spring-mvc.xml:
<!-- 只扫 Controller,不要扫到 Service --> <context:component-scan base-package="com.hotel.controller"/> <!-- 视图解析器:返回的字符串拼成 /WEB-INF/views/xxx.jsp --> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean> <!-- 注解驱动:@RequestMapping、JSON 转换 --> <mvc:annotation-driven/> <!-- 静态资源放行:css/js/图片不经过 Controller --> <mvc:resources mapping="/static/**" location="/static/"/>这里最容易踩的雷是扫描范围串了:spring-mybatis.xml扫 Service,spring-mvc.xml只扫 Controller。如果 Controller 扫描范围误扫了 Service,会出现事务失效、代理对象双份这类怪问题。确认容器边界,是 SSM 排错的第一步。
最后web.xml里挂上监听器和 DispatcherServlet:
<listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mybatis.xml</param-value> </context-param> <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>逻辑说明:ContextLoaderListener创建 Spring 根容器加载spring-mybatis.xml,DispatcherServlet创建 SpringMVC 子容器加载spring-mvc.xml。子容器能看到父容器的 Bean,反之不行。答辩时说出"SpringMVC 容器是 Spring 容器的子容器,Controller 能注入 Service"这层关系,比背十句框架特性都管用。
提示:Tomcat 启动报
ClassNotFoundException,优先查WEB-INF/lib缺不缺 jar。用 Maven 就执行mvn clean package打 war 再部署,能躲掉一堆依赖缺失的玄学问题。
3. 数据库设计:房间、订单、会员三张核心表先把状态想清楚
3.1 核心表结构:状态字段决定业务复杂度
酒店客房预定管理系统的数据库设计,重点不在表的数量,而在状态字段怎么定义。最小闭环需要房间、客户、订单三张表。房间有"空闲、已预订、已入住、维修"四种状态,订单有"待确认、已确认、已入住、已退房、已取消"五种状态,两组状态的联动就是整个系统最核心的业务规则。
| 状态值 | 房间状态 | 业务含义 |
|---|---|---|
| 0 | 空闲 | 可预订、可入住 |
| 1 | 已预订 | 有生效订单,不可再订 |
| 2 | 已入住 | 客人住店中 |
| 3 | 维修 | 人工置为不可用 |
CREATE TABLE `room` ( `room_id` INT NOT NULL AUTO_INCREMENT, `room_number` VARCHAR(20) NOT NULL COMMENT '房间号,如 801', `room_type` VARCHAR(20) DEFAULT '单人间' COMMENT '单人间/双人间/套房', `price` DECIMAL(10,2) NOT NULL COMMENT '门市价', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1已预订 2已入住 3维修', `floor` INT DEFAULT NULL COMMENT '楼层,用于筛选', `description` VARCHAR(255) DEFAULT NULL, PRIMARY KEY (`room_id`), UNIQUE KEY `uk_room_number` (`room_number`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `customer` ( `customer_id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT '存哈希,别存明文', `phone` VARCHAR(20) DEFAULT NULL, `id_card` VARCHAR(18) DEFAULT NULL COMMENT '入住登记用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`customer_id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;参数说明:
status用TINYINT存数字,比VARCHAR存中文更省空间、查询更快,代价是可读性差,所以每条都写COMMENT注释说明枚举含义。- 房间号加唯一索引,防止并发下重复插入同一房间。
- 密码至少要存 MD5,答辩被问"密码怎么存的",答"明文"是硬伤。说"毕业设计用 MD5,生产环境会换 BCrypt 加盐",已经强过大多数同学。
3.2 订单表与预定冲突判断:整个系统最核心的一段 SQL
订单表连接客户和房间。设计上要冗余房间号和一个价格快照,理由很实在:房间价格会调整,但订单必须保留下单那一刻的价格,这个细节答辩时讲出来就是亮点。
CREATE TABLE `reservation` ( `id` INT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号,程序生成', `customer_id` INT NOT NULL, `room_id` INT NOT NULL, `room_number` VARCHAR(20) DEFAULT NULL COMMENT '冗余房间号', `room_type` VARCHAR(20) DEFAULT NULL COMMENT '冗余房型', `price` DECIMAL(10,2) NOT NULL COMMENT '房价快照', `check_in_date` DATE NOT NULL COMMENT '入住日期', `check_out_date` DATE NOT NULL COMMENT '离店日期', `total_price` DECIMAL(10,2) NOT NULL COMMENT '总价=价格×晚数', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1已确认 2已入住 3已退房 4已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_room_date` (`room_id`, `check_in_date`, `check_out_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;预定系统的核心是判断一个房间在日期区间内是否被占用。两个区间没有重叠的条件是"新入住 >= 已有离店"或"新离店 <= 已有入住",取反就是冲突条件,写成 SQL 是经典的四行判断:
-- 判断某房间在 [checkIn, checkOut) 区间内是否已有生效订单 SELECT COUNT(*) FROM reservation WHERE room_id = #{roomId} AND status IN (1, 2) -- 已确认、已入住才算占用 AND #{checkInDate} < check_out_date AND #{checkOutDate} > check_in_date逻辑说明:
- 状态过滤是关键。
待确认和已取消不占用房间,只有已确认和已入住才算数。漏掉这个条件,会出现"待支付订单占着房,别人订不了"的尴尬。 - 比较符号在 MyBatis XML 里必须写成
<和>,直接写<会被 XML 解析器当成标签开头,这是新手最容易卡住的低级坑。 - 区间用左闭右开语义:当天入住、当天离店视为不占用,方便处理连住场景。
3.3 管理端报表:用一条 GROUP BY 撑起营业额统计
管理端统计报表答辩必问。常见需求是"按月统计营业额"和"按房型统计入住率"。营业额统计的 SQL 不复杂,但要选对状态——只有已退房的订单才算完成收款,已确认未入住的不能算进营收:
SELECT DATE_FORMAT(check_out_date, '%Y-%m') AS month, COUNT(*) AS order_count, SUM(total_price) AS revenue FROM reservation WHERE status = 3 -- 已退房,订单完成 AND check_out_date BETWEEN #{startDate} AND #{endDate} GROUP BY DATE_FORMAT(check_out_date, '%Y-%m') ORDER BY month DESC;参数说明:
DATE_FORMAT(check_out_date, '%Y-%m')把日期格式化成年月用于分组。用check_out_date作为统计归属时间,比用create_time更符合酒店财务口径:只有实际离店才产生收入。这个细节讲出来,评委能看出你思考过业务而不是只会调 ORM。- 状态
3必须和你表里的定义一致。很多人把订单状态顺序打乱,报表数据不对时,先回去看建表语句的注释,别急着改代码。 - 入住率统计口径建议提前想好:分子用已入住的
DATEDIFF(check_out_date, check_in_date)求和,分母用房间数乘统计天数。评委很爱追问统计口径。
4. 从 JSP 页面到数据库:一次提交预订请求的完整生命周期
4.1 Controller 层:参数绑定与页面转发
这条主线把一次预订请求从浏览器走到底层数据库。用户在 JSP 页面填好入住和离店日期、选房间、点提交,请求先到ReservationController:
@Controller @RequestMapping("/reservation") public class ReservationController { @Autowired private ReservationService reservationService; @RequestMapping(value = "/submit", method = RequestMethod.POST) public String submit(Reservation reservation, Model model, HttpSession session) { // 从登录态取当前用户,没登录直接踢回登录页 Customer customer = (Customer) session.getAttribute("loginUser"); if (customer == null) { return "redirect:/login.jsp"; } reservation.setCustomerId(customer.getCustomerId()); // 业务层返回自定义结果对象,带回提示信息 Result bizResult = reservationService.createReservation(reservation); model.addAttribute("bizResult", bizResult); if (bizResult.isSuccess()) { return "reservation/success"; } // 失败回填空表单,保留用户已填内容 model.addAttribute("reservation", reservation); return "reservation/book"; } }逻辑说明:
- SpringMVC 把表单里的
name属性自动绑定到Reservation实体同名属性,例如<input name="roomId">提交后reservation.getRoomId()直接有值,省掉手动request.getParameter。 Model.addAttribute往 JSP 传数据,页面用${bizResult.message}读取。redirect:前缀发起新请求,URL 会变;不带前缀的"reservation/success"是服务端转发,URL 不变。这个区别也是高频率面试题,答辩前过一遍。
4.2 Service 层:业务规则集中在这一层
业务层是答辩提问的重灾区,也是代码质量分水岭。下单的 Service 至少做三件事:校验日期合法性、校验房间冲突、在事务里完成订单插入和房间状态更新。
@Service @Transactional public class ReservationServiceImpl implements ReservationService { @Autowired private ReservationMapper reservationMapper; @Autowired private RoomMapper roomMapper; @Override public Result createReservation(Reservation r) { // 1. 入参校验:日期不能倒挂,入住不能早于今天 LocalDate checkIn = r.getCheckInDate(); LocalDate checkOut = r.getCheckOutDate(); if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) { return Result.fail("入住日期必须早于离店日期"); } if (checkIn.isBefore(LocalDate.now())) { return Result.fail("入住日期不能早于今天"); } // 2. 核心冲突校验:查重叠订单数 int overlapCount = reservationMapper.countOverlap( r.getRoomId(), r.getCheckInDate(), r.getCheckOutDate()); if (overlapCount > 0) { return Result.fail("该房间在所选日期内已被预定"); } // 3. 组装订单:生成订单号、计算总价(价格必须服务端算) Room room = roomMapper.selectById(r.getRoomId()); if (room == null || room.getStatus() == 3) { return Result.fail("房间不存在或处于维修状态"); } r.setOrderNo(generateOrderNo()); r.setRoomNumber(room.getRoomNumber()); r.setRoomType(room.getRoomType()); r.setPrice(room.getPrice()); long days = ChronoUnit.DAYS.between(checkIn, checkOut); r.setTotalPrice(room.getPrice().multiply(BigDecimal.valueOf(days))); // 4. 插入订单 + 更新房间状态,同一事务,要么都成要么都回滚 reservationMapper.insert(r); roomMapper.updateStatus(r.getRoomId(), 1); return Result.success("预订成功", r); } private String generateOrderNo() { // 订单号规则:yyyyMMddHHmmss + 4位随机数 return new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + String.format("%04d", (int) (Math.random() * 10000)); } }参数说明:
@Transactional挂在类上,类里 public 方法默认开启事务。第 4 步的insert和updateStatus要么都成功,要么都回滚,不会出现"订单插入成功但房间状态没改"的中间态。- 日期用
java.time.LocalDate,isBefore直接比较。注意老版本 MySQL 驱动(5.1.37 之前)不能直接绑定LocalDate,报类型转换错误的话把实体字段换成java.sql.Date或字符串就能绕过去。 BigDecimal算钱,double 算钱有精度问题,这个点答辩时主动提是加分项。- 总价、状态、订单号全部由服务端决定,绝不从前端接收,防止被抓包篡改。
4.3 Mapper 层与 MyBatis XML:动态 SQL 和参数绑定
Service 里调的countOverlap和insert,SQL 写在ReservationMapper.xml里。SQL 和 Java 分开是 MyBatis 的设计特点,改 SQL 不用重新编译,也方便 DBA 审查。先看接口,多参数必须加@Param才能绑定:
public interface ReservationMapper { // 多参数必须写 @Param,XML 里才能用 #{roomId} 这种占位符 int countOverlap(@Param("roomId") Integer roomId, @Param("checkInDate") LocalDate checkInDate, @Param("checkOutDate") LocalDate checkOutDate); int insert(Reservation reservation); }<select id="countOverlap" resultType="int"> SELECT COUNT(*) FROM reservation WHERE room_id = #{roomId} AND status IN (1, 2) AND #{checkInDate} < check_out_date AND #{checkOutDate} > check_in_date </select> <insert id="insert" parameterType="com.hotel.entity.Reservation" useGeneratedKeys="true" keyProperty="id"> INSERT INTO reservation (order_no, customer_id, room_id, room_number, room_type, price, check_in_date, check_out_date, total_price, status) VALUES (#{orderNo}, #{customerId}, #{roomId}, #{roomNumber}, #{roomType}, #{price}, #{checkInDate}, #{checkOutDate}, #{totalPrice}, 0) </insert>逻辑说明:
#{}是预编译占位符,MyBatis 用?代替并设置参数,能防 SQL 注入。这是必考题,你要答出"#{}走PreparedStatement,${}是字符串拼接有注入风险"。useGeneratedKeys="true"配合keyProperty="id",插入后自增主键回填到实体id属性,后续要拿订单 ID 关联其他表就方便。- 插入时
status写死0,total_price由服务端计算,前端只传日期和房间,这是安全习惯——输入不可信。
4.4 JSP 页面与表单回显:EL 表达式和 JSTL 的配合
视图层用 JSP,核心是 EL 表达式${}和 JSTL 标签。预订表单页面长这样:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <body> <h3>预订房间 - ${room.roomNumber}</h3> <form action="${pageContext.request.contextPath}/reservation/submit" method="post"> <input type="hidden" name="roomId" value="${room.roomId}"/> 入住日期:<input type="date" name="checkInDate" value="${reservation.checkInDate}"/> 离店日期:<input type="date" name="checkOutDate" value="${reservation.checkOutDate}"/> 房价:<span>${room.price} 元/晚</span> <button type="submit">提交预订</button> </form> <c:if test="${not empty bizResult}"> <p style="color:${bizResult.success ? 'green' : 'red'}">${bizResult.message}</p> </c:if> </body> </html>参数说明:
${pageContext.request.contextPath}动态取项目上下文路径,避免部署路径变化导致表单 404。硬编码成/hotel-ssm/...,换台机器或改 war 包名就废了。- EL 取值本质是调用实体 getter,所以实体属性命名要规范,getter/setter 必须齐全。
${bizResult.success ? 'green' : 'red'}三元表达式和c:if配合做条件渲染,这是 JSP 页面最常见的组合写法。
提示:JSP 报 500 时先看 Tomcat 日志第一行异常堆栈,绝大多数是 null 指针,来源往往是 Controller 没往 Model 放数据页面就开始取。
5. SSM + JSP 项目高发坑排查:乱码、404、事务失效,5 个必踩的雷
5.1 数据库中文乱码:从连接 URL 到建表语句,三层都要对齐
现象:页面上中文正常,存进数据库变成???;或者数据库里是中文,页面显示乱码。
原因:字符集不一致。MySQL 表用了utf8mb4,但 JDBC 连接 URL 没指定编码,连接层用了默认的latin1,中文在传输中被转成问号。
解决:三层全部对齐。建库建表统一utf8mb4,JSP 页面顶部设置contentType="text/html;charset=UTF-8",最重要的是连接 URL 追加参数:
jdbc.url=jdbc:mysql://localhost:3306/hotel_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai排查顺序:先SHOW CREATE TABLE room看表字符集,再看连接 URL,最后确认请求编码过滤器在DispatcherServlet之前注册,三步基本定位。
5.2 静态资源 404:spring-mvc.xml 里漏了放行
现象:页面 HTML 结构正常,但 CSS、图片、JS 全部加载失败,F12 控制台一堆 404。
原因:DispatcherServlet的<url-pattern>/</url-pattern>拦了所有请求,包括/static/css/style.css,SpringMVC 默认不认这些静态资源。
解决:在spring-mvc.xml里显式放行:
<mvc:resources mapping="/static/**" location="/static/"/>同时 JSP 里的引用写成${pageContext.request.contextPath}/static/css/style.css。注意location结尾的斜杠不能丢,/static/和/static行为不同,这种细节最耗时间。
5.3 事务注解不生效:三个原因按概率排序
现象:Service 里第一步插入订单成功,第二步更新房间状态抛异常,数据库里订单居然还在,回滚没发生。
原因按概率排序:
spring-mybatis.xml里没写<tx:annotation-driven>,注解被当空气。@Transactional加在非 public 方法上,Spring 的代理只对 public 生效。- 同类内部通过
this调用事务方法,调用走的是原始对象不是代理,拦截器被绕过。
解决:最常见的是第三种。把事务方法拆到独立 Bean,或者把两个数据库操作放同一个 public 方法里,让外部调用走代理。答辩时说出"Spring 事务基于 AOP 代理,内部自调用不走代理所以失效"这句,已经碾压九成同学。
5.4 Mapper 接口与 XML 绑定失败:Invalid bound statement
现象:Tomcat 启动正常,一调用 Mapper 方法就抛org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.hotel.mapper.RoomMapper.selectById。
原因:接口和 XML 没绑上。要么mapperLocations路径和 XML 实际位置对不上,要么 XML 的namespace没写成接口全限定名,要么<select>的id和方法名不一致。
解决:按三个"一致"逐项核对——文件路径一致、namespace 一致、方法名和id一致。如果是 Maven 项目,还要确认打包时src/main/resources/mapper/*.xml被打进WEB-INF/classes/mapper/,文件不在就是打包配置问题。
5.5 依赖版本冲突:Tomcat 启动时 NoClassDefFoundError
现象:本地开发工具里跑得好好的,换台电脑或部署到服务器就报NoClassDefFoundError或LinkageError,尤其集中在javax.servlet和jackson相关类。
原因:Servlet API 依赖的 scope 写错了。默认compile会把javax.servlet-api打进 war 包,和 Tomcat 自带的同路径类冲突。这类问题最玄学,报错位置千奇百怪,根因就那么一个。
解决:把 Servlet 相关依赖的 scope 改成provided:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency>provided的含义是编译需要、运行不打进包,交给 Tomcat 容器提供。顺手在pom.xml的properties里固定 Java 编译版本为 1.8,避免换机器后默认 JDK 不同导致的编译兼容报错。
6. 答辩前的硬核验证:并发订同一间房,系统扛得住吗
验证健壮性最狠的一招是并发订同一间房。操作很简单:开两个无痕窗口,登录两个不同账号,同一秒提交同一房间、同一日期的订单。如果系统只是先查重再插入,两个并发请求会同时查到"无冲突",然后双双插入成功,这就是并发覆盖,也是评委最爱问的"高并发场景怎么办"。
解决办法首选悲观锁,把冲突判断整个放进事务,先锁房间那一行再查重叠订单:
@Transactional public Result createReservation(Reservation r) { // 先锁住这一行,其他并发事务的查询会阻塞在 FOR UPDATE 上 Room room = roomMapper.selectByIdForUpdate(r.getRoomId()); int overlap = reservationMapper.countOverlap( r.getRoomId(), r.getCheckInDate(), r.getCheckOutDate()); if (overlap > 0) return Result.fail("该房间已被预定"); reservationMapper.insert(r); roomMapper.updateStatus(r.getRoomId(), 1); return Result.success("预订成功"); }<select id="selectByIdForUpdate" resultType="com.hotel.entity.Room"> SELECT * FROM room WHERE room_id = #{roomId} FOR UPDATE </select>逻辑说明:FOR UPDATE是 InnoDB 的行级排他锁,一个事务锁住该行后,另一个事务执行同样的查询会阻塞到第一个事务提交或回滚。顺序上必须先锁再查重,两个并发查询才能串行。锁的生命周期依赖事务边界,所以@Transactional不能省。
另一个方案是建唯一索引挡住完全相同的日期,但对"半重叠"区间无效,毕业设计里用FOR UPDATE最好讲。这轮验证做完,你还能把隔离级别、乐观锁和悲观锁的区别串一遍。我的习惯是答辩前一周每天完整走一遍主流程——注册、登录、订房、退房、统计,每步截图。现场先讲并发这个坑和你的解法,再演示主流程,评委基本不会再往死里追问。这套方法带人验证过好几回,至少省三夜通宵。希望帮到你。
本文还有配套的精品资源,点击获取