ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java SSM+JSP酒店管理系统实战:业务建模到部署全解析

Java SSM+JSP酒店管理系统实战:业务建模到部署全解析 前一阵子帮朋友做内部系统聊起他当年的毕业设计就是Java基于SSMJSP的酒店管理系统。这个题目在CSDN、GitHub上一搜一大把看起来就是个标准的增删改查但真正动手做过的人会知道它其实把Java Web里最经典的几块内容全串起来了Spring的IoC和事务、SpringMVC的请求流转、MyBatis的数据映射、JSP的服务端渲染再加上酒店业务里最要命的房态管理和订单并发处理。这篇文章我就把自己做这个项目的完整思路和踩过的坑整理出来从业务建模、技术选型、核心代码实现、翻车记录到最后的打包部署一条线讲清楚给准备拿这个题目练手或者做毕设的同学做个参考。1. 先把业务啃透酒店管理系统的实体关系与状态流转很多同学拿到这种题目第一反应是建几个表、写几个页面把增删改查跑通就算完事。但酒店管理系统和普通的图书管理、学生管理系统有个本质区别它的核心不是增删改查而是状态。房间从空闲到已订、从已订到入住、从入住到退房每个动作都牵扯到后续一系列操作。所以开工之前先把业务模型理顺比先写代码重要得多。1.1 管理系统不是表单收集器核心是状态机我们模拟一下酒店前台最日常的几个场景客人来了要查房、开房走了要退房、结账中间还可能换房、续住、加床房间可能因为设施损坏进入维修状态。剥掉这些表面动作底层其实就是两种核心对象的不断交互房间Room每个房间都有类型标间、大床房、套房价格楼层以及最重要的——当前房态。订单Order每一笔预订或入住都生成一条订单订单关联房间、客户、时间段、金额。客房状态我建议用统一的枚举管理一般分四类就够了状态含义触发动作FREE空闲可订可入住退房完成、清扫完成BOOKED已预订已被预订但未入住客户下单预订CHECKED_IN已入住客人已办理入住到店check inREPAIR维修/保洁房间暂停售卖设施损坏或大扫除订单状态也可以扁平化处理待入住、已入住、已退房、已取消。前台所有操作本质上都是把房间和订单的状态从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. SSMJSP这套组合为什么直到今天还有人在用打开招聘网站一看现在Java岗位基本都是Spring Boot 微服务那一套SSM听起来确实有点old school。但为什么酒店管理系统这类的毕设题年年还是它因为SSM不是被淘汰了它只是被打包升级了——Spring Boot的内核就是SpringSpringMVC仍然是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不熟、跨域问题一堆、接口文档也不会写最后时间全耗在了部署和联调上核心业务反而写得很糙。我的判断标准很简单SSMJSP适合管理系统类的内部工具、课程设计或毕设、需要在答辩时当场讲清框架原理的项目。服务端渲染本身能保证首屏快不用处理CORS页面由Controller直接跳转整套流程非常闭环。Spring Boot Vue适合有真实C端用户、需要多端复用接口、想做独立前端工程化构建、打包、部署分离的项目。并且你确实有足够时间学协调开发。如果你和我一样是拿SSMJSP来打基础完全没必要焦虑。把这套东西吃透后面写Spring Boot时你会发现只是配置少了、约定多了除此之外事务、注入、请求映射这些东西全是相通的。3. 订房主链路拆解从JSP表单到数据库的一整条调用链基础架构选定了就看核心功能怎么落地。我们挑一个最容易出问题的功能——预订房间把整条链路走一遍。为什么挑它因为订房同时涉及数据校验、房态变更、订单插入、并发防重四个环节是最能体现工程能力的一段代码。3.1 JSP表单和Controller参数接收与回显JSP页面就是一个HTML内嵌Java代码片段表单提交是最传统的方式form action${pageContext.request.contextPath}/order/create methodpost input typehidden nameroomId value${room.id} / div label客户姓名/label input typetext namecustomerName required / /div div label手机号/label input typetext namecustomerPhone pattern1[3-9]\d{9} required / /div div label入住时间/label input typedate namecheckinTime required / /div div label退房时间/label input typedate namecheckoutTime required / /div button typesubmit提交预订/button /form这里用${pageContext.request.contextPath}拼路径是为了部署时不管项目名叫什么都能正确请求到。使用POST而不是GET是因为这是一个带副作用的写操作刷新浏览器时也不能让浏览器用GET重新请求一遍。Controller负责接收参数和做基础校验。参数接收我用一个DTO对象承接而不是写一堆散落的RequestParam这样方法签名更直观后面加字段也不用改ControllerController 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。看这条SQLupdate idupdateStatusIfCurrent update room set room_status #{targetStatus} where id #{roomId} and room_status #{currentStatus} /updateupdate语句执行时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 onloadlocation.reload()里写定时刷新每次页面加载完自动刷一次。这种写法的初衷我能理解但它完全是在错误的路线上补窟窿——如果你的页面数据需要刷新问题大概率出在后端没有正确重定向而不是前端没有强制刷新。遇到这种需求正确的办法是检查Controller的操作后返回逻辑而不是在页面上搞骚操作。4.2 PageHelper分页查询的串数据事故列表页肯定要做分页。我当时用的是PageHelper插件写法也照着文档来的PageHelper.startPage(pageNum, pageSize); ListOrderVO orders orderMapper.selectOrderList(queryDTO); PageInfoOrderVO 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加URIEncodingUTF-8那是Tomcat 7之前的老黄历了。你在新环境下一旦照抄反而可能把问题搞复杂。真正需要检查的就三个地方第一数据库连接URL加编码参数jdbc:mysql://localhost:3306/hotel?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/Shanghai缺了characterEncodingUTF-8JDBC驱动和MySQL服务端通信用的字符集就是默认的latin1中文数据存进去再查出来就直接变成???。这个参数加上之后乱码90%都能解决。第二JSP页面声明编码% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %第三请求参数编码。POST请求乱码在SpringMVC里配置一个CharacterEncodingFilter就能搞定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注意forceEncoding要设为true否则它只在请求没有指定编码时才生效POST里如果带了content-type为text/html的空编码就过滤不进去了。设成true会强制把请求和响应都转成UTF-8一劳永逸。5. 打包部署与后续升级war包、配置外置和向Spring Boot迁移折腾完全部代码最后一步是上线。很多新手从来没自己打过war包都是IDE里点了Run就完事真正部署到Linux服务器上就抓瞎。这里把流程走一遍。5.1 Maven打war包并部署到TomcatSSM项目是标准的Web应用最终产物是war包。用IDEA的Maven面板执行clean package可以在target目录下生成xxx.war。把这个war包扔到Tomcat的webapps目录下启动Tomcat它会自动解压部署。这是最直接的做法。如果你的Tomcat是9.x要注意它对应的是Servlet 4.0规范项目里的javax.servlet依赖版本别搞太低。pom里记得加上war插件明确指定最终包名build finalNamehotel-manager/finalName plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-war-plugin/artifactId version3.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依赖并且打包方式必须选warjar包方式默认不支持JSP资源的classpath加载同时把启动类继承SpringBootServletInitializer重写configure方法。所以前一步SSM项目打war包的经验迁到Boot这里一样用得上。最后如果你不排斥换前端技术栈我建议Boot版本直接配Thymeleaf或者干脆上Vue做前后端分离。Thymeleaf语法和JSP的EL表达式有点像迁移成本不高Vue则能让页面交互上一个台阶但学习成本和时间成本也需要有心理准备。写在最后真做完这个项目我的最大体会是酒店管理系统的代码量不大但业务状态的准确流转比代码本身难十倍。你要是时间紧一定要先把预订-入住-退房这条主链路做扎实业务状态用条件更新来防并发提交操作一律重定向分页startPage后面别插其它查询——这四条记住项目就成功了一半。至于页面UI能简单就简单先把核心业务跑顺再回头慢慢美化。如果后面有机会把这套系统迁到Spring Boot或者加个小程序端记得回来再写一篇续集。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进