前一阵子帮朋友做内部系统,聊起他当年的毕业设计,就是"Java基于SSM+JSP的酒店管理系统"。这个题目在CSDN、GitHub上一搜一大把,看起来就是个标准的增删改查,但真正动手做过的人会知道,它其实把Java Web里最经典的几块内容全串起来了:Spring的IoC和事务、SpringMVC的请求流转、MyBatis的数据映射、JSP的服务端渲染,再加上酒店业务里最要命的房态管理和订单并发处理。这篇文章我就把自己做这个项目的完整思路和踩过的坑整理出来,从业务建模、技术选型、核心代码实现、翻车记录到最后的打包部署,一条线讲清楚,给准备拿这个题目练手或者做毕设的同学做个参考。
1. 先把业务啃透:酒店管理系统的实体关系与状态流转
很多同学拿到这种题目第一反应是建几个表、写几个页面,把增删改查跑通就算完事。但酒店管理系统和普通的图书管理、学生管理系统有个本质区别:它的核心不是"增删改查",而是状态。房间从空闲到已订、从已订到入住、从入住到退房,每个动作都牵扯到后续一系列操作。所以开工之前,先把业务模型理顺,比先写代码重要得多。
1.1 管理系统不是表单收集器,核心是状态机
我们模拟一下酒店前台最日常的几个场景:客人来了要查房、开房,走了要退房、结账,中间还可能换房、续住、加床,房间可能因为设施损坏进入维修状态。剥掉这些表面动作,底层其实就是两种核心对象的不断交互:
- 房间(Room):每个房间都有类型(标间、大床房、套房),价格,楼层,以及最重要的——当前房态。
- 订单(Order):每一笔预订或入住都生成一条订单,订单关联房间、客户、时间段、金额。
客房状态我建议用统一的枚举管理,一般分四类就够了:
| 状态 | 含义 | 触发动作 |
|---|---|---|
| FREE(空闲) | 可订可入住 | 退房完成、清扫完成 |
| BOOKED(已预订) | 已被预订但未入住 | 客户下单预订 |
| CHECKED_IN(已入住) | 客人已办理入住 | 到店check in |
| REPAIR(维修/保洁) | 房间暂停售卖 | 设施损坏或大扫除 |
订单状态也可以扁平化处理:待入住、已入住、已退房、已取消。前台所有操作,本质上都是"把房间和订单的状态从A推到B",而且这两个状态之间是绑定的——你不能在房间还是FREE的时候把订单变成已入住,也不能在客人还没退房的时候把房间改成空闲。
1.2 数据表设计与字段细节
实体关系理清之后,数据库结构就顺理成章了。核心表不需要多,五张足够支撑一个完整版本:
-- 房间类型表 CREATE TABLE room_type ( id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(50) NOT NULL, -- 如:标准间/大床房 price DECIMAL(10,2) NOT NULL, -- 门市价,注意用DECIMAL bed_info VARCHAR(100), -- 床型说明 max_people INT DEFAULT 2 -- 最多入住人数 ); -- 房间表 CREATE TABLE room ( id INT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(20) NOT NULL UNIQUE, -- 房间号,如5101 room_type_id INT NOT NULL, -- 关联room_type floor INT, -- 楼层,方便按楼层筛选 room_status VARCHAR(20) DEFAULT 'FREE',-- 状态枚举 version INT DEFAULT 0, -- 乐观锁版本号,后面并发用 FOREIGN KEY (room_type_id) REFERENCES room_type(id) ); -- 客户表 CREATE TABLE customer ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, id_card VARCHAR(30), -- 证件号,前端要做脱敏展示 phone VARCHAR(20) ); -- 订单表 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE, -- 业务单号,建议用时间戳+随机数 customer_id INT NOT NULL, room_id INT NOT NULL, order_status VARCHAR(20) DEFAULT 'WAIT_CHECK_IN', -- 状态枚举 checkin_time DATETIME NOT NULL, -- 计划入住时间 checkout_time DATETIME NOT NULL, -- 计划退房时间 actual_cost DECIMAL(10,2) NOT NULL, -- 总金额 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_room_time (room_id, checkin_time, checkout_time) );这里有几个我实际做下来觉得特别值得注意的细节:
第一,金额一律用DECIMAL(10,2),千万别用double。我之前见过有人用double存金额,算个8.18加0.01变成8.190000000000001,打印出来前台妹子直接懵了。Money的计算必须用BigDecimal,这是Java基础里的老生常谈,但项目里真正做到的人反而不多。
第二,订单表里不要存储"入住时长"这种字段,它可以通过checkin_time和checkout_time计算出来。时间区间用两个DATETIME表示,并且一定要建联合索引,因为后面查"某段时间哪些房间空闲"的SQL基本都会带上这两个字段做范围查询。
第三,业务单号order_no别用自增id充数,对外展示的单号都要看起来像那么回事,可以用yyyyMMddHHmmss + 随机四位拼,唯一性自己控制一下就行。
1.3 状态流转的"唯一入口"设计
状态字段谁都能改是个大坑。如果每个Controller都自己发SQL去update房态,后期逻辑会迅速失控。我的做法是:所有状态变更统一收敛到Service层,提供语义化方法,比如bookRoom()、checkIn()、checkOut()、cancelOrder(),不要在Controller里直接调Mapper去update。
这样设计的理由很简单:状态迁移往往伴随多个表的联动修改。订房要改room的状态、要insert orders;办理入住要改order状态、记录入住时间;退房要算钱、改房态、生成账单。如果散落各处,哪天报错你根本不知道是哪一步漏了。收敛成方法之后,事务边界也清晰了,一个方法里要么全部成功要么全部回滚。
2. SSM+JSP这套组合为什么直到今天还有人在用
打开招聘网站一看,现在Java岗位基本都是Spring Boot + 微服务那一套,SSM听起来确实有点old school。但为什么酒店管理系统这类的毕设题年年还是它?因为SSM不是被淘汰了,它只是"被打包升级"了——Spring Boot的内核就是Spring,SpringMVC仍然是Boot里最主流的Web框架,MyBatis也是Boot生态里最常见的持久层方案。搞懂SSM,再看Boot的项目基本是无痛切换。
2.1 每一层到底在干什么
很多新手把SSM的配置文件一抄,代码一跑通,但问起每个框架的职责边界就说不清了。这里用一个生活化的类比:前台点餐。
- Spring容器相当于餐厅的货仓和总管。所有对象(Bean)的创建、组装都由它负责,服务员(Controller)要什么食材直接声明,不需要自己new。它还负责AOP事务,相当于给每个操作加了"要么全上菜、要么原样退回"的记账规则。
- SpringMVC相当于服务员,负责接单(接收HTTP请求)、帮你点好菜(参数绑定)、把菜端上桌(渲染JSP页面)。它的核心是DispatcherServlet,所有请求先经过它统一派发。
- MyBatis相当于传菜窗口的订单条。你只需要把SQL写在XML或注解里,它负责结果集和Java对象的自动映射,避免手写冗长的JDBC样板代码。
- JSP则是最后呈现出来的成品菜——服务端把数据填进HTML模板,直接返回完整页面给浏览器。
SSM这套组合的分层非常清晰:Controller管请求、Service管业务、Mapper管数据。写出来的代码结构天然适合课堂讲解和答辩展示。这恰恰是它作为毕设题受欢迎的原因——评委一看你的分层结构,就知道你确实理解了分层架构。
2.2 和Spring Boot、前后端分离怎么选
不是所有项目都适合Spring Boot + Vue的。我见过很多人为了"显得高级"强行上前后端分离,结果Vue不熟、跨域问题一堆、接口文档也不会写,最后时间全耗在了部署和联调上,核心业务反而写得很糙。
我的判断标准很简单:
SSM+JSP适合:管理系统类的内部工具、课程设计或毕设、需要在答辩时当场讲清框架原理的项目。服务端渲染本身能保证首屏快,不用处理CORS,页面由Controller直接跳转,整套流程非常闭环。
Spring Boot + Vue适合:有真实C端用户、需要多端复用接口、想做独立前端工程化(构建、打包、部署分离)的项目。并且你确实有足够时间学协调开发。
如果你和我一样是拿SSM+JSP来打基础,完全没必要焦虑。把这套东西吃透,后面写Spring Boot时你会发现只是"配置少了、约定多了",除此之外,事务、注入、请求映射这些东西全是相通的。
3. 订房主链路拆解:从JSP表单到数据库的一整条调用链
基础架构选定了,就看核心功能怎么落地。我们挑一个最容易出问题的功能——预订房间,把整条链路走一遍。为什么挑它?因为订房同时涉及数据校验、房态变更、订单插入、并发防重四个环节,是最能体现工程能力的一段代码。
3.1 JSP表单和Controller:参数接收与回显
JSP页面就是一个HTML内嵌Java代码片段,表单提交是最传统的方式:
<form action="${pageContext.request.contextPath}/order/create" method="post"> <input type="hidden" name="roomId" value="${room.id}" /> <div> <label>客户姓名</label> <input type="text" name="customerName" required /> </div> <div> <label>手机号</label> <input type="text" name="customerPhone" pattern="1[3-9]\d{9}" required /> </div> <div> <label>入住时间</label> <input type="date" name="checkinTime" required /> </div> <div> <label>退房时间</label> <input type="date" name="checkoutTime" required /> </div> <button type="submit">提交预订</button> </form>这里用${pageContext.request.contextPath}拼路径是为了部署时不管项目名叫什么都能正确请求到。使用POST而不是GET,是因为这是一个带副作用的写操作,刷新浏览器时也不能让浏览器用GET重新请求一遍。
Controller负责接收参数和做基础校验。参数接收我用一个DTO对象承接,而不是写一堆散落的@RequestParam,这样方法签名更直观,后面加字段也不用改Controller:
@Controller @RequestMapping("/order") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/create") public String createOrder(@ModelAttribute OrderCreateDTO dto, RedirectAttributes redirectAttributes) { // 基础校验:退房必须晚于入住 if (dto.getCheckoutTime().isBefore(dto.getCheckinTime())) { redirectAttributes.addFlashAttribute("error", "退房时间必须晚于入住时间"); return "redirect:/room/list"; } try { int orderId = orderService.createOrder(dto); return "redirect:/order/detail?id=" + orderId; } catch (BizException e) { redirectAttributes.addFlashAttribute("error", e.getMessage()); return "redirect:/room/list"; } } }注意最后返回的是redirect:前缀,这是SpringMVC的标准写法,提交类操作完成后一定要重定向,不能直接forward回页面。原因留到第四章专门讲,这里先记住这条规矩。
3.2 Service层:事务边界和脏读拦截
Service层是业务逻辑的重心。这段代码我重点讲两点:一是事务怎么加,二是订房时如何防止"同一间房被两个人同时订走"。
@Service public class OrderServiceImpl implements OrderService { @Autowired private RoomMapper roomMapper; @Autowired private OrderMapper orderMapper; @Autowired private CustomerMapper customerMapper; @Override @Transactional(rollbackFor = Exception.class) public int createOrder(OrderCreateDTO dto) { // 1. 保存或查找客户 int customerId = customerMapper.saveIfNotExists(dto.getCustomerName(), dto.getCustomerPhone()); // 2. 通过“条件更新”抢占房间状态 int affected = roomMapper.updateStatusIfCurrent(dto.getRoomId(), RoomStatus.FREE.name(), RoomStatus.BOOKED.name()); if (affected == 0) { throw new BizException("该房间已被预订或不在可售状态,请重新选择"); } // 3. 生成订单 OrderPO order = new OrderPO(); order.setOrderNo(generateOrderNo()); order.setCustomerId(customerId); order.setRoomId(dto.getRoomId()); order.setCheckinTime(dto.getCheckinTime()); order.setCheckoutTime(dto.getCheckoutTime()); order.setOrderStatus(OrderStatus.WAIT_CHECK_IN.name()); order.setActualCost(calcCost(dto)); orderMapper.insert(order); return order.getId(); } }事务注解用的是@Transactional(rollbackFor = Exception.class)。默认情况下Spring只对RuntimeException回滚,但订房时如果抛了自定义的BizException(它不一定继承RuntimeException),事务就会悄悄提交,房间状态改了、订单却没生成,这种不一致比报错更可怕。所以只要是业务事务,我都习惯显式指定rollbackFor为Exception。
核心的防并发逻辑是updateStatusIfCurrent。看这条SQL:
<update id="updateStatusIfCurrent"> update room set room_status = #{targetStatus} where id = #{roomId} and room_status = #{currentStatus} </update>update语句执行时,MySQL会对命中行加写锁,两个并发事务同时update同一条房间记录时,只有一个能返回影响行数1,另一个拿到的是0。影响行数为0就说明房间已经被别人抢占了,直接抛异常回滚。这种方式比"先select再判断再update"安全得多,后者在并发下必然出现两个人同时查到FREE,然后都把房间订走的经典事故。
3.3 房态和订单的联动校验
除了订房,入住、退房也要做同样的"条件更新",只是状态的前后值不同:
- 入住:订单状态从
WAIT_CHECK_IN变CHECKED_IN,房间从BOOKED变CHECKED_IN。 - 退房:订单状态从
CHECKED_IN变FINISHED,房间从CHECKED_IN变FREE。
如果客人是直接到店开房(没有预订),那就更简单:房间从FREE直接到CHECKED_IN,订单直接生成并设置为已入住。
还有一个容易被忽略的点:退房时要计算实际房费。如果客人提前走或者续住,不能拿预订时预填的价格直接收。我的做法是拆出来一个calcActualCost方法,按实际入住天数乘以房型单价,超过退房时间再按小时加收超时费。这部分逻辑单独写一个工具类,不要塞在Controller里。
4. 实战趟雷实录:重复提交、分页串台和字符集问题
这一章是全篇最有价值的部分。上面那些知识,正规教程和培训视频都会讲;但下面这些坑,不亲手踩一遍、不熬夜排查一遍,你永远不知道原来还有这种事。
4.1 表单提交成功后刷新页面,订单怎么重复了
这是一个特别经典的场景:前台录入一个订单,提交成功后觉得不放心,习惯性按了一下F5刷新,结果又插入了一条一模一样的订单。为什么会这样?
因为表单是用POST直接提交的,提交成功后浏览器当前URL还是那个/order/create。此时按F5,浏览器会提示"确认重新提交表单",不管你点确认还是取消,底层都会把这个POST请求原样再发一遍。后端没有幂等处理,于是问题就出现了——房态被update了两次,订单插了两条。
解决办法就是我在第三章Controller里写的return "redirect:/order/detail?id=" + orderId。Post-Redirect-Get模式:提交完POST之后,返回一个302重定向地址,浏览器自动跳转到GET请求的详情页。此时地址栏已经是新的URL,刷新这个GET请求不会有任何副作用。
我见过有些老项目的JSP为了解决"页面数据不新鲜",在<body onload="location.reload()">里写定时刷新,每次页面加载完自动刷一次。这种写法的初衷我能理解,但它完全是在错误的路线上补窟窿——如果你的页面数据需要刷新,问题大概率出在后端没有正确重定向,而不是前端没有强制刷新。遇到这种需求,正确的办法是检查Controller的操作后返回逻辑,而不是在页面上搞骚操作。
4.2 PageHelper分页查询的"串数据"事故
列表页肯定要做分页。我当时用的是PageHelper插件,写法也照着文档来的:
PageHelper.startPage(pageNum, pageSize); List<OrderVO> orders = orderMapper.selectOrderList(queryDTO); PageInfo<OrderVO> pageInfo = new PageInfo<>(orders);结果有一天前台和我说:订单列表翻到第二页,有几条数据显示的是别页的内容,而且不同的人查出来的第二页数据不一样。我的第一反应是SQL写错了,把orderMapper的SQL翻来覆去看了三遍,没发现问题。然后我又怀疑是不是Mapper缓存的问题,清了缓存重试,还是复现。
后来我把mybatis的SQL日志打开,发现了一件诡异的事:selectOrderList查出来的limit条件居然是LIMIT 10,10没错,但日志里在它前面多出来一条完全无关的查询也被拼了LIMIT。我查了那条无关查询的代码路径才发现,原来service里在调用分页查询之前还调用了另一条非分页的查询(查一个字典配置),而PageHelper的机制是基于ThreadLocal的:startPage()只负责把分页参数放进当前线程的ThreadLocal,然后拦截紧接着执行的那一条查询(只要有select且没有被手动拼limit)。
所以当你的代码是:
PageHelper.startPage(pageNum, pageSize); orderMapper.selectConfigList(); // 先执行了这条 orderMapper.selectOrderList(queryDTO); // 分页参数已经被上一条“消费”掉了分页参数就作用到了selectConfigList上,然后该方法自己又被清空,导致selectOrderList查出了全量数据。多线程场景更隐蔽——每个线程的ThreadLocal互相隔离,你很难从表象推断出是哪个请求串了。
正解就一句话:startPage()后面必须紧跟你要分页的那条查询,中间不要插入任何其它SQL操作。如果Service里确实需要先执行别的查询,那就把先查询的逻辑挪到startPage()之前,或者把查询逻辑重新编排。这条经验我后来写了张便签贴显示器上。
4.3 中文乱码的"考古"现场
做SSM项目,中文乱码可以算得上一个"传世经典"。现在用Tomcat 8+和MySQL 5.7+其实不太容易乱码,但我发现网上大量老博客还在教人改Tomcat的server.xml加URIEncoding="UTF-8",那是Tomcat 7之前的老黄历了。你在新环境下一旦照抄,反而可能把问题搞复杂。
真正需要检查的就三个地方:
第一,数据库连接URL加编码参数:
jdbc:mysql://localhost:3306/hotel?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai缺了characterEncoding=UTF-8,JDBC驱动和MySQL服务端通信用的字符集就是默认的latin1,中文数据存进去再查出来就直接变成"???"。这个参数加上之后,乱码90%都能解决。
第二,JSP页面声明编码:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>第三,请求参数编码。POST请求乱码在SpringMVC里配置一个CharacterEncodingFilter就能搞定:
<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>注意forceEncoding要设为true,否则它只在请求没有指定编码时才生效,POST里如果带了content-type为text/html的空编码,就过滤不进去了。设成true会强制把请求和响应都转成UTF-8,一劳永逸。
5. 打包部署与后续升级:war包、配置外置和向Spring Boot迁移
折腾完全部代码,最后一步是上线。很多新手从来没自己打过war包,都是IDE里点了Run就完事,真正部署到Linux服务器上就抓瞎。这里把流程走一遍。
5.1 Maven打war包并部署到Tomcat
SSM项目是标准的Web应用,最终产物是war包。用IDEA的Maven面板执行clean package,可以在target目录下生成xxx.war。把这个war包扔到Tomcat的webapps目录下,启动Tomcat,它会自动解压部署。这是最直接的做法。
如果你的Tomcat是9.x,要注意它对应的是Servlet 4.0规范,项目里的javax.servlet依赖版本别搞太低。pom里记得加上war插件,明确指定最终包名:
<build> <finalName>hotel-manager</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <version>3.3.2</version> </plugin> </plugins> </build>部署后访问路径就是http://ip:8080/hotel-manager/。如果你想直接通过根路径访问,把war包改名为ROOT.war放进去就行。
5.2 配置外置与数据库初始化脚本
SSM项目的数据库连接配置通常存在jdbc.properties里,打war包时会一起打包。但这有个隐患:部署到测试服务器,数据库地址变了,总不能去改war包里的配置吧。
我后来习惯把配置外置:让项目先从外部文件系统读配置,读不到再用默认值。比如Tomcat会把它自己的根目录暴露为系统变量catalina.home,可以在配置类里用System.getProperty("catalina.home")拼一个外部路径,然后把数据库连接文件放到该路径下。这样换环境部署只需要改服务器上的配置文件,war包本身不用动。
数据库初始化脚本一定要跟着项目走。网上很多源码只给你一堆建表语句,没有初始数据,连个能登录的账号都没有,跑起来一脸懵。我通常会在项目根目录放一个doc/init.sql,里面包含建库语句、建表语句、默认管理员账号和几个房型示例数据。这个脚本要保证能一键执行:
mysql -uroot -p < init.sql这样无论你自己换电脑还是同事接手,十分钟就能跑起来一个带数据的完整系统,不用从零手工插数据。
5.3 向Spring Boot迁移的实操建议
如果你做完这个SSM项目,后面想把它升级成Spring Boot版本写进简历,我可以分享几个实际改造的思路:
首先,Spring Boot会帮你自动装配大部分配置,SSM里最头疼的spring-mvc.xml、mybatis-config.xml、web.xml可以全部删掉,换成@Configuration类或者直接在application.yml里声明。Mapper接口用@MapperScan("com.xxx.mapper")扫描,比逐条配置省事得多。
其次,JSP在Spring Boot里不是一等公民。Boot默认的spring-boot-starter-web用的是内嵌Tomcat,对JSP支持不友好。如果坚持用JSP,必须在pom里同时引入spring-boot-starter-tomcat和tomcat-embed-jasper依赖,并且打包方式必须选war(jar包方式默认不支持JSP资源的classpath加载),同时把启动类继承SpringBootServletInitializer重写configure方法。所以前一步"SSM项目打war包"的经验,迁到Boot这里一样用得上。
最后,如果你不排斥换前端技术栈,我建议Boot版本直接配Thymeleaf或者干脆上Vue做前后端分离。Thymeleaf语法和JSP的EL表达式有点像,迁移成本不高;Vue则能让页面交互上一个台阶,但学习成本和时间成本也需要有心理准备。
写在最后
真做完这个项目,我的最大体会是:酒店管理系统的代码量不大,但业务状态的准确流转比代码本身难十倍。你要是时间紧,一定要先把"预订-入住-退房"这条主链路做扎实,业务状态用条件更新来防并发,提交操作一律重定向,分页startPage后面别插其它查询——这四条记住,项目就成功了一半。至于页面UI,能简单就简单,先把核心业务跑顺,再回头慢慢美化。
如果后面有机会把这套系统迁到Spring Boot或者加个小程序端,记得回来再写一篇续集。