Spring Boot酒店管理系统:从自动装配到并发防重实战
简介基于SpringBoot的酒店管理系统设计与实现文档面向计算机专业毕业设计或课程设计场景帮助学习者解决传统酒店人工管理效率低、预定与入住信息难同步的问题。资源为单个docx格式文档压缩包大小约3.11MB文件体积轻量但内容完整覆盖从系统需求分析、框架选型到具体功能模块设计的完整流程。文档采用标准论文结构包含中英文摘要、绪论及系统设计章节内容组织清晰。已有54人学习下载适合正在开展酒店管理类项目或需要撰写相关论文的学生参考。全文围绕SpringBoot框架、MySQL数据库与IntelliJ IDEA开发环境的组合展开依次介绍前台、用户、管理员、员工四类界面并详细阐述客房预订、客房信息管理、入住安排管理等核心模块可作为毕业设计说明书基础、系统开发蓝图或课题答辩参考资料。1. 酒店管理系统是个springboot练手项目也是并发与状态机的一座坑一个二十多间房的小酒店前台拿着一份Excel排房表客人电话预订时先靠记忆找空房退房时再靠Excel翻历史。重复预订和账单改错是每天都会发生的常规事故。酒店管理系统要解决的不是“弄个网页录单”而是三件事房型与房价的维护、订单从预订到离店的状态流转、以及退房时的账单核算。Spring Boot把这类系统的起步成本压到了最低——自动配置、内嵌容器打一个jar包但真正决定项目能不能用的是数据模型和并发控制。这里直接从一个springboot酒店管理系统设计与实现的角度把工程骨架、表结构、预订接口、redis缓存、登录拦截和并发防重一条线讲下来。适合准备spring boot项目的毕业生也适合想快速搭一个中小型管理后台的Java工程师。2. springboot工程骨架版本选择、包结构与自动装配很多人的第一个坑不是业务代码而是版本。Spring Boot目前主流是2.7.x和3.x2.7.x基于Spring Framework 5.3支持Java 8到Java 21代码里还在用javax.命名空间3.x基于Spring Framework 6强制Java 17以上命名空间整体改成jakarta.。按旧教程写import javax.servlet.*放到3.x工程里编译直接报错反过来把3.x的代码挪回2.7又会出现依赖版本对不齐的情况。先把版本关系理清楚后面的代码才不用反复改import。2.1 版本与JDK先解决springboot版本太高导致的“起不来”问题先用一个表格把关键差异挑出来再决定pom怎么写对比项Spring Boot 2.7.xSpring Boot 3.2.x最低JDKJDK 8JDK 17命名空间javax.*jakarta.*Spring Framework5.3.x6.1.x嵌入式Tomcat9.x10.1.x配置文件模型与3.x大体一致与2.7基本兼容对于酒店管理系统这种以CRUD和状态流转为核心的工程钉在2.7.18这类稳定历史版本上是比较稳的组合历史版本资料多教程、遇到的报错都好搜想体验新特性再上3.2.x配JDK 17。pom.xml里用spring-boot-starter-parent当parent能少写一堆版本号!-- 历史版本组合Spring Boot 2.7.18 JDK 8 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies这段依赖只包含web、data-jpa和mysql驱动是最小可跑的组合。注意mysql-connector-java在Spring Boot 3.x里改成了com.mysql:mysql-connector-j自动版本管理会带上对应驱动2.7.x里写mysql-connector-java没问题。第一次启动如果数据源连不上Spring Boot会直接抛Failed to configure a DataSource原因大概率是没配置spring.datasource.url先配数据库再跑。补充一句遇到“springboot版本太高”导致的老项目依赖不兼容比如Log4j、Quartz的groupId和版本对不上先看Maven仓库里该版本的dependency-management不要动不动就强依赖外部本地jar。版本锁死了后面的自动装配才有讨论基础。2.2 包结构controller-service-repository在酒店项目里的对应关系酒店管理系统的分层不用画太复杂三层足够。控制层只接收参数和回显结果服务层管业务规则和事务数据层不出现任何业务判断。这样面试时被问“一个订单从创建到退房谁负责状态流转”答案很干净service层。src/main/java/com/example/hotel ├── HotelApplication.java ├── config │ ├── RedisConfig.java │ └── WebMvcConfig.java ├── controller │ ├── AuthController.java │ ├── RoomController.java │ └── ReservationController.java ├── service │ ├── RoomService.java │ ├── ReservationService.java │ └── BillService.java ├── repository │ ├── RoomRepository.java │ ├── ReservationRepository.java │ └── BillRepository.java ├── entity │ ├── Room.java │ ├── Reservation.java │ └── Bill.java └── common ├── Result.java └── BusinessException.javacommon下的Result是统一返回结构酒店前台接口不太可能给你时间去定制各种报文格式Result保持三个字段就够code、message、data。BusinessException配合全局异常处理避免Service里抛出一堆RuntimeException没人管。entity与表一一对应repository只放接口Spring Data JPA在启动时自动生成实现类不需要手动写DAO实现。2.3 springboot自动装配原理为什么加依赖就能跑起来启动类上只有一个SpringBootApplication它其实由SpringBootConfiguration、EnableAutoConfiguration和ComponentScan组合。真正干重活的是EnableAutoConfiguration它会去classpath下读取自动配置类的文件名列表从Spring Boot 2.7开始这类文件是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports把里面列出的类按条件注解加载。每个自动配置类上都有一堆ConditionalOnClass和ConditionalOnProperty。比如你引入了redis的starter但没配置spring.redis.host配置类会按照默认值创建连接工厂连接真正失败是在第一次操作时暴露而不是“加了依赖就生效”。排查这类问题有一个实用开关启动参数加--debug或配置文件写debug: true启动日志会把所有自动配置类的匹配和不匹配原因输出成ConditionEvaluationReport。看到Negative matches下面写的为什么没生效再对症处理比盲目加注解高效得多。注意--debug只影响自动配置报告不影响业务日志级别调试完记得去掉否则日志量会明显变大。3. 房型、订单、账单酒店核心数据模型与接口实现房间的业务数据其实只有三类房、单、账。很多springboot酒店系统做不好不是框架问题而是表结构里没有体现出房态与订单状态的边界。房间表里别放一段备注扛业务订单表别把账单拆成几十行冗余字段。先把最小可用的表结构定下来。3.1 数据模型room、reservation、bill三张核心表的设计先给出三张最小可用表CREATE TABLE room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(10) NOT NULL, room_type VARCHAR(20) NOT NULL, price DECIMAL(10,2) NOT NULL, status VARCHAR(10) NOT NULL DEFAULT FREE, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_room_no (room_no) ); CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, room_id BIGINT NOT NULL, customer_name VARCHAR(50) NOT NULL, phone VARCHAR(20), check_in_date DATE NOT NULL, check_out_date DATE NOT NULL, total_amount DECIMAL(10,2), status VARCHAR(10) NOT NULL, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_order_no (order_no) ); CREATE TABLE bill ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reservation_id BIGINT NOT NULL, room_amount DECIMAL(10,2), extra_amount DECIMAL(10,2) DEFAULT 0, paid_amount DECIMAL(10,2) DEFAULT 0, settle_date DATETIME );从字段角度解释room.status用一个不超过10的字符串FREE表示空闲、BOOKED表示已预订、CHECKED_IN表示在住比0/1/2的数字魔法值更好读排查时不用翻代码猜含义。reservation单独拆order_no业务上用户拿订单号对账、前台按订单号查操作记录比拿自增id可靠check_in_date和check_out_date用DATE类型跨天计算时比TIMESTAMP清爽。version列给乐观锁用第4章会用到。表名核心字段状态取值说明roomroom_no / room_type / priceFREE、BOOKED、CHECKED_IN排房时靠status过滤reservationorder_no / room_id / check_in_date / check_out_dateCREATED、PAID、CHECKED_IN、CHECKED_OUT订单生命周期由service维护billreservation_id / room_amount / paid_amount无状态字段退房时生成并结算3.2 用Spring Data JPA还是MyBatis Plus我选JPA的理由酒店系统表不多、关联不深Spring Data JPA是Spring Boot官方默认接口方法名可以直接表达查询意图findByStatus、findByRoomIdAndStatus。MyBatis Plus也很好但需要额外引入依赖和XML或注解SQL。两者都能做但JPA在“按日期区间查可用房”这类查询上更直接不用手写复杂的动态SQL。Entity Table(name room) public class Room { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name room_no, length 10, nullable false) private String roomNo; Column(name room_type, length 20, nullable false) private String roomType; Column(name price, nullable false) private BigDecimal price; Column(name status, length 10, nullable false) private String status; Column(name version, nullable false) private Integer version; // getter / setter 省略按业务需要补充 }Room实体里没有写ManyToOne之类的复杂关联一间房在订单里只存roomId。酒店系统的查询大多是按日期和状态过滤不维护外键对象反而让接口更好读。接下来是Repository接口注意这里Query里写的是JPQL而不是原生SQLpublic interface RoomRepository extends JpaRepositoryRoom, Long { Query(select r from Room r where r.roomType :roomType and r.status FREE and r.id not in (select res.room.id from Reservation res where res.status in (CREATED,PAID,CHECKED_IN) and res.checkInDate :checkOutDate and res.checkOutDate :checkInDate)) ListRoom findAvailableRooms(String roomType, LocalDate checkInDate, LocalDate checkOutDate); }这个查询把“可用房”定义清楚先限定房间状态是FREE再排除那些订单时间与本次入住区间有重叠的房间。其中res.checkInDate :checkOutDate and res.checkOutDate :checkInDate是判断两个日期区间重叠的经典写法比分别比较“入住日是否在某订单范围内”要严谨能覆盖提前到店和延迟离店的情况。3.3 预订接口Controller、Service与事务边界创建预订是最容易出现“数据库有空房实际订不到”的地方核心事务要放在service方法上Controller只做参数接收和结果返回RestController RequestMapping(/api/reservation) public class ReservationController { private final ReservationService reservationService; public ReservationController(ReservationService reservationService) { this.reservationService reservationService; } PostMapping public ResultOrderVO create(Valid RequestBody CreateOrderRequest req) { OrderVO order reservationService.create(req); return Result.ok(order); } }Service里的create方法重点看事务边界和房态占用的顺序Transactional public OrderVO create(CreateOrderRequest req) { Room room roomRepository.findById(req.getRoomId()) .orElseThrow(() - new BusinessException(房间不存在)); if (!FREE.equals(room.getStatus())) { throw new BusinessException(该房间已被预订); } String orderNo generateOrderNo(); Reservation res new Reservation(); res.setOrderNo(orderNo); res.setRoomId(room.getId()); res.setCheckInDate(req.getCheckInDate()); res.setCheckOutDate(req.getCheckOutDate()); res.setStatus(CREATED); res reservationRepository.save(res); // 条件更新房态影响行数为0说明被并发抢先 int rows roomRepository.updateStatus(room.getId(), FREE, BOOKED); if (rows 0) { throw new BusinessException(手慢了房间刚被订走); } return toVO(orderNo, room.getPrice(), res); }Transactional保证这中间任何一步抛异常前面保存的订单会一起回滚不会出现“订单存在但房态还是FREE”的脏数据。两个坑需要记住一是Transactional自调用时失效比如类内部一个普通方法调另一个带Transactional的方法事务不生效二是这里先save订单再更新房态updateStatus返回0时抛出BusinessException整个事务回滚正好把订单删掉。4. redis缓存、登录拦截与并发防重让springboot酒店系统跑稳后台系统一旦同时有两三个前台在操作问题就都会冒出来同一间房被订两次、“我明明下单了但页面没反应”、退房时账单对不上。前面把表结构和接口搭起来只是第一步稳定运行要靠缓存、拦截器和并发控制。4.1 用redis缓存房型与今日房价防击穿的兜底做法房价不会每分钟变但前台查房、算房价的请求每分钟都有。用redis缓存可以明显减少数据库压力。springboot使用redis的方式很成熟加starter配连接信息然后在service里定义缓存Key。application.yml中的redis配置spring: redis: host: 127.0.0.1 port: 6379 timeout: 3s注意Spring Boot 2.x用spring.redis.hostSpring Boot 3.x用spring.data.redis.host。这个配置位置变化就是版本升级时最常见的“redis在springboot中的使用”报错点。再看一个service里用redis的写法Service public class PriceService { private static final String PRICE_KEY hotel:price:roomType:%s; private final StringRedisTemplate stringRedisTemplate; public PriceService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } public BigDecimal getPrice(String roomType) { String key String.format(PRICE_KEY, roomType); String cached stringRedisTemplate.opsForValue().get(key); if (cached ! null) { return new BigDecimal(cached); } BigDecimal price queryPriceFromDb(roomType); int ttl 600 new Random().nextInt(60); stringRedisTemplate.opsForValue().set(key, price.toString(), ttl, TimeUnit.SECONDS); return price; } }随机过期时间的目的很明确避免同一类Key在同一秒集体失效流量同时打到数据库这是缓存击穿的一种轻量解法。如果queryPriceFromDb这一步也慢可以把数据库查询结果在进程内再放一份但不要为了这个需求引入一套完整本地缓存框架酒店管理系统的规模用不上。4.2 用HandlerInterceptor做登录拦截轻量不需要Spring Security酒店后台的页面少、接口也不多登录拦截用Spring MVC自带的HandlerInterceptor就够了比引入Spring Security整套配置轻得多。拦截器里看到没有token就返回401再由全局异常处理器统一包装JSON。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !TokenStore.isValid(token)) { response.setStatus(401); return false; } return true; } }注册拦截器明确放行登录、静态资源与健康检查Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /error, /actuator/health); } }addPathPatterns写/api/**表示只拦截业务接口excludePathPatterns里放登录接口和actuator健康检查否则前台登录前连健康检查都打不通。TokenStore这里可以用一个简单的ConcurrentHashMap模拟正式项目换redis加有效期过期判断逻辑不变。4.3 并发下单与防超卖条件更新、乐观锁与幂等号酒店房间数量少但订单集中在节假日同一间房被两个前台抢订的场景是真实存在的。并发防超卖在springboot里最常用的是条件更新对应的SQL长这样UPDATE room SET status BOOKED, version version 1 WHERE id ? AND status FREE影响行数为0说明房间状态已经不是FREE直接让这次请求失败。乐观锁则更通用在实体上加Version更新时JPA自动带上版本条件。对于酒店管理这种并发量级条件更新已经够用不需要上分布式锁。防重还有一个容易被忽略的点订单幂等。同样一个订单被用户双击提交两次会产生两笔预订。用uk_order_no这个唯一索引约束配合orderNo的前缀设计日期加随机数第二次插入唯一键冲突时捕获DuplicateKeyException返回“订单已提交”不要向上抛500。表结构里的uk_order_no就是为了兜住这种情况。方案适用场景缺点条件更新UPDATE ... WHERE statusFREE房间数少、状态简单需要每次带状态条件乐观锁version通用实体更新冲突后需要重试Redis分布式锁跨房间批量操作需要额外维护锁过期这三层想清楚面试被问“如何避免两个前台同时订到一间房”就能讲出完整链路先查房态再条件更新房态落到订单时用唯一键防重复。5. 上线前的一组springboot自查traceId、actuator与接口验证5.1 加一个traceId日志字段退房对账不再靠眼睛翻串订单处理日志如果只有线程名和类名出问题时要在十几条相同样式的info里靠人工反复对照。常见的做法是在日志pattern里加入MDC字段最合适的key就是traceId。pattern%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - [%X{traceId}] %msg%n/pattern在拦截器里往MDC放UUID请求结束后清理Override public boolean preHandle(...) throws Exception { MDC.put(traceId, UUID.randomUUID().toString().replace(-, )); // 原有逻辑不变 } Override public void afterCompletion(...) throws Exception { MDC.remove(traceId); }5.2 用actuator做只读健康检查别把所有端点都暴露出去pom里加spring-boot-starter-actuator配置文件只暴露health和info保证不会把beans、env这些内部信息暴露到外网。management: endpoints: web: exposure: include: health,info验证Health端点是否正常的命令curl http://127.0.0.1:8080/actuator/health返回{status:UP}说明应用和数据源都正常比看启动日志更直接。5.3 用一条curl把预订-房态-账单串联起来写一个验证脚本顺序调用登录、创建订单、查询房态检查返回值把接口级回归从手动点页面变成一条命令。脚本里可以这样写curl -s -X POST http://127.0.0.1:8080/api/auth/login curl -s -X POST http://127.0.0.1:8080/api/reservation \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d {roomId:1,checkInDate:2025-07-10,checkOutDate:2025-07-12,customerName:张三}第一个curl拿到token后第二个curl创建订单。正常返回orderNo再查一次房间接口确认room状态已经变成BOOKED这条链路就算通了。最后把日志里的traceId和订单号一起归档到本地出问题时第一件事就是grep traceId。本文还有配套的精品资源点击获取