简介:基于SSM+MySQL的酒店客房预定管理系统是一份面向计算机专业毕业设计、课程设计场景的完整项目资源包,覆盖个人中心、用户管理、客房类型、客房预定、开房记录、退房结账等核心业务模块,前后端技术栈与权限角色设计完整,适合需要快速搭建可运行管理系统并撰写配套文档的开发者。压缩包共包含1254个文件,其中Java源码、JSP页面、JS/CSS前端资源为主要构成,另有SQL脚本、XML配置、设计文档与PPT演示等,整体大小18.9MB,目录结构清晰,便于按模块查阅与二次开发。项目附带部署说明和视频演示,能够帮助使用者降低环境搭建门槛,从数据库初始化到功能联调均可按步骤复现。资源已有185人学习,作为毕设参考或练手项目均具实用价值,可直接基于源码二次扩展业务逻辑,如增加报表统计或线上支付等。
1. 基于SSM+MySQL的酒店客房预定管理系统:一个能跑通的课设,更是一套可落地的业务骨架
如果你在为Java课程设计选题发愁,或者公司需要一个内网跑的轻量客房管理系统,SSM+MySQL的酒店客房预定管理系统几乎是绕不开的经典方案。它用Spring管理Service和事务、SpringMVC接收页面请求、MyBatis读写MySQL,把房态查询、客房预定、入住退房、订单管理这一整套流程完整串起来。压缩包里通常装的是源码工程、设计文档、部署说明和演示视频,这意味着你不需要从零摸黑起步——先看演示视频对齐预期,再照着部署说明把环境拉起来,最后改源码里的业务细节。新手能靠它把SSM整合的每个配置关卡逐个打通,老手则能用它作为快速交付内网系统的脚手架。
2. SSM整合这样配:两个容器各管各的事,别让Spring和SpringMVC打架
SSM这套组合的难点不在业务代码,而在配置文件的职责边界。很多人第一次整合失败,是因为把Spring和SpringMVC的配置混在一个文件里,或者重复扫描包导致Bean冲突。做这个客房系统,我的建议是先理清三个角色的分工:Spring容器管Service、数据源、事务;SpringMVC容器管Controller、视图解析、静态资源;MyBatis只管SQL映射。下面从依赖开始,把每一份配置的用途讲清楚。
2.1 为什么这个系统用SSM而不是直接上Spring Boot
Spring Boot确实省事,但SSM的价值在于每个环节都是显式配置——数据源怎么创建、事务怎么生效、URL怎么映射到方法,全部看得见。课设和设计文档恰恰需要这种透明性:答辩时老师问“事务怎么控制的”,你能直接指出是哪个Bean在管,而不是笼统说“自动配置”。另一方面,很多老项目上线四五年仍然是SSM架构,能看懂这套配置,比只会用全家桶更有承接价值。客房预定这种业务不重、表不超过十张的系统,SSM比Spring Boot多的那点配置量完全可接受,却能换来对底层机制的理解,这笔买卖划算。
选型代价也客观存在:XML多、依赖版本需要手动对齐、Java环境和Tomcat版本不匹配时坑不少。但把这套配置存成模板后,再开新模块只是复制粘贴改包名。这也是我推荐这个题目作为入门课设的原因——它逼你把Spring的IoC、AOP和MyBatis的代理机制切实过一遍,而不是被自动配置喂着走。
2.2 打底依赖与web.xml:先把入口和编码钉死
Maven的pom.xml是整个工程的地基,核心依赖就五块:Spring WebMVC、MyBatis、mybatis-spring整合包、MySQL驱动、Druid连接池。建议把所有版本收在一个properties节点里,后续升级依赖只改一处,避免出现“Spring 5.0配MyBatis 3.2”这种老掉牙组合。
<!-- pom.xml 关键依赖 --> <properties> <spring.version>5.3.39</spring.version> <mybatis.version>3.5.16</mybatis.version> </properties> <!-- Spring MVC 和 Spring 基础 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 核心 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <!-- MyBatis 整合 Spring 的桥接包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <!-- MySQL 驱动,直接用 8.x --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- Druid 连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency>版本这样定有我的考虑:Spring 5.3.x要求JDK 8以上,与JDK 8/11/17都能兼容,不会因为JDK版本卡壳;MySQL驱动直接用8.0.33,避开5.x驱动连MySQL 8时的认证报错;Druid的监控页面在调试连接池问题时很好用,毕竟连接泄漏这种问题只能靠工具去看。再强调一次,凡是和Spring关联的依赖,版本号尽量统一,避免两个同组件的JAR在classpath里打架。
接下来是web.xml,这是Tomcat加载工程的入口文件。重点做三件事:配置字符编码过滤器、声明Spring容器、挂载DispatcherServlet。
<!-- web.xml 入口配置 --> <web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" version="3.0"> <!-- 1. 编码过滤器:必须在所有Servlet之前,解决POST乱码 --> <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> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 2. Spring容器监听器:加载applicationContext.xml --> <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> <!-- 3. 前端控制器:只拦所有请求,不拦JSP --> <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> </web-app>CharacterEncodingFilter必须放在最前面,并且url-pattern要用/*,否则SpringMVC内部的请求流转绕过了编码过滤器,POST表单提交的中文照样乱码。DispatcherServlet的映射用/而不是/*,这是一个高频翻车点:/*会把JSP请求也拦截掉,Tomcat找不到JSP页面直接404。Spring容器和SpringMVC容器在此刻明确分家——ContextLoaderListener加载的是applicationContext.xml,DispatcherServlet加载的是spring-mvc.xml,两边各扫各的包。
2.3 两个配置文件怎么分工:事务放Spring,映射放SpringMVC
applicationContext.xml管数据源、SqlSessionFactory、Mapper扫描和事务,scope是全局的。spring-mvc.xml只管Controller、视图解析器和静态资源放行。我见过不少工程把数据源直接写在spring-mvc.xml里,结果事务注解一直不生效,排查很久才发现是容器加载顺序问题——Controller先被SpringMVC扫描,而事务管理器还在Spring容器那边,两者没有绑定关系。
<!-- applicationContext.xml:只扫Service,不扫Controller --> <context:component-scan base-package="com.hotel.service"/> <context:property-placeholder location="classpath:db.properties"/> <!-- Druid数据源 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <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"/> <!-- 最大活跃连接数,客房系统并发量不大,10足够 --> <property name="maxActive" value="10"/> </bean> <!-- MyBatis 整合:SqlSessionFactory --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <!-- Mapper XML 放哪,让 MyBatis 自己扫 --> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <!-- 扫描Mapper接口,自动生成代理对象 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.hotel.mapper"/> </bean> <!-- 事务管理器:DataSourceTransactionManager --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>MapperScannerConfigurer会让Spring自动扫描com.hotel.mapper包下的所有接口,并为每个接口生成代理对象,放进Spring容器。这样Service里直接@Autowired一个Mapper接口就能用,不用手写实现类。事务管理器绑定的是同一个dataSource,所以@Transactional标注的Service方法在运行时走的是同一个连接,事务才真正生效。
spring-mvc.xml那边相对简单:开启注解驱动、放行静态资源、配置视图解析器、只扫Controller包。
<!-- spring-mvc.xml --> <mvc:annotation-driven/> <mvc:default-servlet-handler/> <context:component-scan base-package="com.hotel.controller"/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>mvc:default-servlet-handler是救命的:不加它,CSS、JS、图片会被DispatcherServlet拦住,页面样式全丢,但后端接口又正常,排查时很容易忽略。ViewResolver的prefix指向WEB-INF/views/,即JSP放在WEB-INF下,用户不能通过URL直接访问,只能经Controller跳转,这也是出于安全考虑。至此,SSM的骨架已经立起来,接下来该设计数据库了。
3. 数据库设计先想清楚房态与订单状态:五张表加状态流转,比堆字段更重要
客房预定系统最核心的不是CRUD,而是状态机。房间有自身状态,订单也有自身状态,两者在预定、入住、退房、取消几个动作下联动变化。很多新手直接把“是否被预定”做成订单表里的一个字段,导致同一房间能被重复预定——本质上是状态设计缺位。我的建议是:先画状态流转图,再写建表语句,最后才写业务代码。
3.1 先定状态机再建表:房态与订单状态联动
房间状态我习惯用四个值:0空闲、1已预定、2已入住、3打扫中。订单状态用六个值:0已创建、1待入住、2已入住、3已退房、4已取消、5已超时。操作时两个状态必须同步变化,否则会出现“订单显示已入住,房间还是空闲”这种翻车现场。核心联动关系如下表:
| 操作 | 房间状态变化 | 订单状态变化 |
|---|---|---|
| 用户提交预定 | 空闲 → 已预定 | 已创建 → 待入住 |
| 前台办理入住 | 已预定 → 已入住 | 待入住 → 已入住 |
| 办理退房 | 已入住 → 打扫中 | 已入住 → 已退房 |
| 取消预定 | 已预定 → 空闲 | 已创建/待入住 → 已取消 |
这个表建议直接写进设计文档,答辩时非常好讲。注意退房时房态是“打扫中”而不是直接“空闲”,这意味着保洁完成前,前台不能把同一间房卖出去,实务中这个细节很加分。
3.2 三张核心表的DDL:room、customer、reservation
数据库字符集直接上utf8mb4,排序规则用utf8mb4_general_ci即可。MySQL 8.0默认是utf8mb4_0900_ai_ci,如果你从5.7导入SQL脚本,建议统一改成general_ci,避免两个库的环境不一致。
-- 创建数据库 CREATE DATABASE hotel_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 房间表 CREATE TABLE room ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '房间ID', room_no VARCHAR(10) NOT NULL UNIQUE COMMENT '房号:如 101、202', room_type TINYINT NOT NULL COMMENT '1单人间 2标间 3大床房 4套房', price DECIMAL(10,2) NOT NULL COMMENT '门市价/晚', status TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1已预定 2已入住 3打扫中', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_status (status) ) ENGINE=InnoDB COMMENT='房间表'; -- 顾客表 CREATE TABLE customer ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT '顾客姓名', phone VARCHAR(20) NOT NULL COMMENT '手机号', id_card VARCHAR(18) DEFAULT NULL COMMENT '身份证号,可空', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINE=InnoDB COMMENT='顾客表'; -- 预订单表 CREATE TABLE reservation ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号,全局唯一', room_id INT NOT NULL COMMENT '关联room.id', customer_id INT NOT NULL COMMENT '关联customer.id', check_in_date DATETIME NOT NULL COMMENT '预计入住时间', check_out_date DATETIME NOT NULL COMMENT '预计退房时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0已创建 1待入住 2已入住 3已退房 4已取消 5已超时', total_amount DECIMAL(10,2) DEFAULT NULL COMMENT '实收金额,退房时计算', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_room_status (room_id, status), KEY idx_customer (customer_id), CONSTRAINT fk_res_room FOREIGN KEY (room_id) REFERENCES room(id), CONSTRAINT fk_res_customer FOREIGN KEY (customer_id) REFERENCES customer(id) ) ENGINE=InnoDB COMMENT='预订单表';room表里没有直接放“入住日期”这类时间字段,而是单独拆出status列,原因是房态查询是前台最高频操作,等值查询配合索引就能秒出结果;如果每次判断空房都要去关联订单表做日期范围匹配,请求一多性能立刻露馅。version字段是乐观锁预留的,在并发预定时用来防止超卖,后面章节会用到。reservation表的联合索引idx_room_status (room_id, status)是最重要的一条索引,用于快速查某个房间的当前有效订单;create_time没有加索引是因为后台报表查询频次低,避免索引冗余。
提示:金额字段必须用DECIMAL(10,2),不要用FLOAT或DOUBLE。浮点数在累加和比较时会产生精度误差,房费对账时差几分钱,排查起来极其痛苦。
3.3 排序、事务与分页的三个细节
MySQL排序有个容易忽略的点:ORDER BY room_no在字符串类型下是字典序,“102”会排在“99”前面。如果房号可能超过百位,建议单独加一个纯数字的排序字段,或者把房号设计成INT类型。事务隔离级别默认的REPEATABLE_READ就够用,关键不在隔离级别,而在防并发的手段——先锁行再判断,第4章会演示具体写法。分页查询建议直接用LIMIT #{offset}, #{pageSize},但注意offset值过大会变慢,房间表就几十行数据,这个问题可以忽略;倒是有个容易踩的坑:分页时如果没有ORDER BY,MySQL不保证两次查询结果一致,所以前台房态列表一定要带排序条件。
另外,如果MySQL安装的是8.0版本,JDBC连接串必须显式带上serverTimezone=Asia/Shanghai和useSSL=false,否则时序对象映射到Java的LocalDateTime会偏差8小时,SSL握手还会拖慢连接速度。这是从MySQL安装配置教程到实际项目之间最容易被忽略的一环。
4. 核心业务模块这样写:预定加锁、入住改状态、退房算钱,一条链路走通
数据库设计完成后,业务代码要解决三个问题:并发下预定不超卖、入住退房的状态联动、订单金额的可靠计算。SSM的常用注解在这个环节全部上场:@Service声明业务Bean、@Transactional管理事务、@Autowired注入依赖、@RequestMapping映射URL。下面按流程把关键代码拆开讲。
4.1 预定房间:先锁行再建单,杜绝并发超卖
预定接口是整个系统最敏感的一环。两个前台同时操作,可能都查到“这间房空闲”,然后各自插入一张订单。解决办法是把判断和写入放进同一个事务,并且用SELECT ... FOR UPDATE先把目标房间的行锁住。
@Service public class ReservationService { @Autowired private RoomMapper roomMapper; @Autowired private ReservationMapper reservationMapper; /** * 创建预定 * 整个方法必须在事务中执行,否则行锁会在 SQL 执行完立即释放 */ @Transactional public Reservation createReservation(ReservationDTO dto) { // 1. 锁住目标房间的行,阻止其他事务同时读同一条记录 Room room = roomMapper.selectForUpdate(dto.getRoomId()); if (room == null || room.getStatus() != RoomStatus.FREE) { throw new BizException("该房间当前不可预订,请刷新房态"); } // 2. 校验时间是否重叠:同房间在有效状态下不能有交叉订单 int overlap = reservationMapper.countOverlap( dto.getRoomId(), dto.getCheckInDate(), dto.getCheckOutDate()); if (overlap > 0) { throw new BizException("该时间段已有订单,请更换日期或房间"); } // 3. 更新房态为已预定 roomMapper.updateStatus(room.getId(), RoomStatus.BOOKED, room.getVersion()); // 4. 插入订单 Reservation reservation = new Reservation(); reservation.setOrderNo(generateOrderNo()); reservation.setRoomId(room.getId()); reservation.setCustomerId(dto.getCustomerId()); reservation.setCheckInDate(dto.getCheckInDate()); reservation.setCheckOutDate(dto.getCheckOutDate()); reservation.setStatus(ReservationStatus.CREATED); reservationMapper.insert(reservation); return reservation; } }对应的Mapper XML里,锁定和重叠查询两条SQL是核心:
<!-- RoomMapper.xml --> <!-- 带行锁的查询:FOR UPDATE 必须在事务内才有效 --> <select id="selectForUpdate" resultType="Room"> SELECT * FROM room WHERE id = #{roomId} FOR UPDATE </select> <!-- ReservationMapper.xml --> <!-- 时间重叠判断:统计有效状态下的交叉订单数 --> <select id="countOverlap" resultType="int"> SELECT COUNT(*) FROM reservation WHERE room_id = #{roomId} AND status IN (0, 1, 2) AND #{checkInDate} < check_out_date AND #{checkOutDate} > check_in_date </select>两个细节务必注意:第一,FOR UPDATE在非事务环境里会立刻释放锁,等于锁了个寂寞,所以@Transactional不能省略;第二,MyBatis的XML里小于号<必须转义成<,大于号可以不转,但这个坑非常隐蔽,报错时多半是“元素类型必须由匹配的结束标记终止”。重叠判断用status IN (0,1,2)过滤掉已取消、已退房、已超时的历史订单,否则老订单会把新房客的预定永远卡死。
4.2 入住与退房:状态联动与房费计算
入住和退房的操作逻辑简单,但状态联动必须写全。入住时订单从“待入住”切到“已入住”,房间从“已预定”切到“已入住”;退房时订单切“已退房”,房间切“打扫中”。
/** * 办理入住 */ @Transactional public void checkIn(Integer reservationId) { Reservation r = reservationMapper.selectByIdForUpdate(reservationId); if (r == null || r.getStatus() != ReservationStatus.BOOKED) { throw new BizException("订单不存在或已失效"); } reservationMapper.updateStatus(reservationId, ReservationStatus.CHECKED_IN); roomMapper.updateStatus(r.getRoomId(), RoomStatus.OCCUPIED); } /** * 办理退房:计算金额并更新房态 */ @Transactional public void checkOut(Integer reservationId) { Reservation r = reservationMapper.selectByIdForUpdate(reservationId); if (r == null || r.getStatus() != ReservationStatus.CHECKED_IN) { throw new BizException("订单未入住,不能退房"); } BigDecimal amount = calcAmount(r); reservationMapper.checkOut(reservationId, amount); roomMapper.updateStatus(r.getRoomId(), RoomStatus.CLEANING); } /** * 房费计算规则: * 房费 = 过夜天数 × 门市价 * 14:00前退房不另收费;14:00-18:00加收半天;18:00后加收全天 */ private BigDecimal calcAmount(Reservation r) { long nights = ChronoUnit.DAYS.between( r.getCheckInDate().toLocalDate(), r.getCheckOutDate().toLocalDate()); BigDecimal amount = r.getRoomPrice().multiply(BigDecimal.valueOf(nights)); // 判断延迟退房 LocalTime leaveTime = r.getCheckOutDate().toLocalTime(); if (leaveTime.isAfter(LocalTime.of(18, 0))) { amount = amount.add(r.getRoomPrice()); } else if (leaveTime.isAfter(LocalTime.of(14, 0))) { amount = amount.add(r.getRoomPrice().divide(BigDecimal.valueOf(2), 2, RoundingMode.HALF_UP)); } return amount; }calcAmount把计费规则收敛在一个方法里,后续要调整“周末加价”或“旺季调价”,只需改这一处,订单表里只落一个total_amount字段,对账时不用临时反推。有一点要注意:selectByIdForUpdate同样需要事务配合,防止两个管理员同时给同一单退房,产生重复退房费。
4.3 Controller层与JSON交互:统一返回结构
Controller层不写业务逻辑,只做参数接收和结果封装。页面用Ajax提交JSON,后端返回统一的结果对象,方便前端处理成功和失败两种状态。
@Controller @RequestMapping("/api/reservation") public class ReservationController { @Autowired private ReservationService reservationService; /** * 创建预定 * @RequestBody 接收JSON,@ResponseBody 返回JSON */ @PostMapping("/create") @ResponseBody public Result create(@RequestBody ReservationDTO dto) { try { Reservation reservation = reservationService.createReservation(dto); return Result.success(reservation); } catch (BizException e) { return Result.error(e.getMessage()); } } /** * 办理入住 */ @PostMapping("/checkIn") @ResponseBody public Result checkIn(@RequestBody CheckInDTO dto) { try { reservationService.checkIn(dto.getReservationId()); return Result.success(null); } catch (BizException e) { return Result.error(e.getMessage()); } } /** * 办理退房 */ @PostMapping("/checkOut") @ResponseBody public Result checkOut(@RequestBody CheckOutDTO dto) { try { reservationService.checkOut(dto.getReservationId()); return Result.success(null); } catch (BizException e) { return Result.error(e.getMessage()); } } }Result是统一封装,至少包含code、message、data三个字段。code=200表示成功,其他为失败。前端拿到非200的code就直接弹message,这样业务异常不会以HTTP 500的形式暴露给用户,体验和排查都顺很多。SSM常用注解里,@RequestBody可以把前端的JSON自动绑定到DTO对象,省去手动JSON.parse的功夫;但注意DTO里的日期字段要用@DateTimeFormat(pattern="yyyy-MM-dd HH:mm")声明格式,否则前端传的字符串匹配不上,直接报400。
5. 部署与调试避坑指南:从MySQL连不上到并发超卖,五个血泪问题逐个击破
SSM项目的部署难度不在Tomcat,而在MySQL版本、字符集、配置加载顺序这些“看不见的地方”。下面五条是这套系统最容易踩的坑,每一条我都经历过至少一次,按现象→原因→解决列清楚。
5.1 MySQL 8.0连接报错:Public Key Retrieval is not allowed
现象:Tomcat启动时报Cannot create PoolableConnectionFactory,或者本地Navicat连接正常但Java程序连不上。原因:MySQL 8.0默认使用caching_sha2_password认证插件,老版本驱动(5.1.x)不支持;即使驱动换成8.x,如果连接串没有允许公钥检索,第一次连接时也会报Public Key Retrieval is not allowed。解决:驱动统一换成mysql-connector-java8.0.33,连接串带上三个参数:
jdbc.url=jdbc:mysql://localhost:3306/hotel_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8顺便说一句,如果你是自己安装的MySQL 8.0,安装完毕后用ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '密码';把root切回旧认证方式,能省下后续不少兼容性麻烦。
5.2 页面和数据库中文全是问号
现象:插入的中文在数据库里变成??,或者页面显示乱码。原因:字符集没有贯穿全链路。JSP页面是GBK,数据库是utf8,连接串没指定编码,三层各管各的。解决:三处统一成UTF-8——第一,JSP页面contentType="text/html; charset=UTF-8";第二,JDBC连接串加characterEncoding=utf8;第三,建库时用utf8mb4。web.xml里的CharacterEncodingFilter只是兜底,它的encoding参数如果设成UTF-8而页面本身是GBK,反而会二次乱码。检查顺序:先看数据库存储是否正常,再看页面渲染,最后看HTTP响应头。
5.3 静态资源404,Controller请求也404
现象:CSS、JS加载不出,或者某个URL明明写了@RequestMapping却返回404。原因:典型的是DispatcherServlet的url-pattern用了/*把JSP也拦了;或者是配置了<mvc:annotation-driven/>但没配default-servlet-handler,静态资源全被前端控制器接管。解决:url-pattern一律用/;spring-mvc.xml里保留<mvc:default-servlet-handler/>。如果Controller也404,检查Spring容器和SpringMVC容器是否各自扫描了正确的包——Controller被扫进Spring容器,而SpringMVC容器里没有,同样会出现请求无法映射的问题。
5.4 MyBatis启动报错:Mapped Statements collection already contains value
现象:Tomcat启动时报Mapped Statements collection already contains value for com.hotel.mapper.RoomMapper.selectForUpdate。原因:Mapper XML的namespace写重了,或者同一个namespace下两条SQL的id重复。最常见的场景是复制粘贴了别的XML文件后忘记改namespace。解决:打开mapper目录逐个核对XML,namespace必须等于Mapper接口的全限定名,每条SQL的id必须与接口方法名一致且不能重复。这个报错属于“启动即暴露”的显性错误,比运行期再炸好排查得多。
5.5 并发预定同一间房,两单都成功
现象:两个前台同时给顾客预定同一房间,订单表里出现两条有效订单,但房间只有一间。原因:业务代码写成“先查空闲→再插入订单”,两个事务同时查到空闲,随后各自插入,没有锁的约束。解决:预定方法加上@Transactional,用SELECT ... FOR UPDATE锁定房间行,让第二个事务等待第一个事务提交后再读取。测试方法很简单:打开两个浏览器标签,同时提交同一房间的预定请求,看是否只有一个成功。这个血泪教训说明,数据库的行锁不是可有可无的优化,而是业务正确性的底线。
6. 验收时用一套清单走全流程,再往生产方向补两刀
拿到这套系统,先别急着改代码,花半小时做一轮完整验收,确认骨架没有暗病。我习惯按这个顺序过:房态列表能按类型筛选,预定同一房间的日期不重叠,入住后房间状态自动变更为已入住,退房后房间进入打扫中状态,修改房态后列表立即刷新。再重点压一下并发:用两个浏览器同时提交同一房间、同一时间段的预定,看是否只有一个成功,这是检验FOR UPDATE有没有真正生效的最直接手段。
清理掉测试数据后,把hotel_db的导出SQL作为初始数据一并保存,以后部署新环境就靠这一份脚本初始化。部署时把工程用Maven打成WAR包放进Tomcat的webapps目录,启动前确认db.properties里的IP、端口、库名改为目标机器实际值。MySQL 8.0的连接串记得带上serverTimezone=Asia/Shanghai,这是从本地到服务器最常见的时差问题来源。
如果还想往生产方向靠一靠,优先做两件事。第一,加权限控制,用Shiro或Spring Security把“房间维护”“前台操作”“报表查看”三个角色分开,避免一个账号能改房价又能办退房;第二,把房费规则抽成配置表,存周中/周末价格、旺季浮动比例、延迟退房的计费节点,业务改价不再发版,只改数据库记录。订单表本身已经有room_id、status、total_amount、create_time,跑日营收报表就是一条GROUP BY DATE(create_time) SUM(total_amount),不用再动表结构。
这套SSM+MySQL的客房系统,业务规模不大,却是理解Java Web分层最完整的载体。我做这类项目时最大的教训是:永远先把状态流转画明白再写代码,数据库里一条脏状态比十个Bug都难清。希望你能把这套系统的每一层配置吃透,以后再遇到Spring Boot的自动配置,也能一眼看穿它背后在做什么。希望帮到你。
本文还有配套的精品资源,点击获取