“区域酒店住宿信息系统”这类毕设标题,乍看就是一套普通的 CRUD 管理系统,很多同学拿到选题的第一反应是“做几个页面,增删改查一下就完事”。但真正动手之后才发现问题全在后面:区域怎么存、房型库存怎么算、两个用户同时订同一间房怎么处理、下单之后要不要发通知、数据出错了怎么排查。这篇博客我会把一套区域酒店住宿管理平台的完整落地过程拆开讲,从角色与模块设计、Spring Boot 技术选型、订单核心链路,到 Redis Stream 异步消息、项目监控和常见问题排查,全程用能直接复现的代码和关键细节,尽量帮你把毕业设计做出可以写进简历的深度。
1. 整体设计先别急着建表:把角色、模块和流程画明白
很多同学做毕设的习惯是拿到题目就打开数据库工具开始建表,这是最容易被答辩老师一眼看穿的做法。一套合格的区域酒店住宿信息系统,表面上是让用户“看酒店、订房间”,但背后其实牵扯到三类完全不同的使用角色,以及它们各自的流程闭环。先把这些理清楚,后面的代码才不会被反复推翻。
1.1 三类角色边界怎么划
我建议第一版就按三类角色来设计:普通住客用户、酒店运营方(房东/前台)、平台管理员。不要觉得角色多就复杂,实际上这是酒店预订场景里最小的完整闭环。
普通用户的核心诉求是:按目标区域找酒店、看房型和实时剩余可用房间数量、选定入住离店日期后下单、查看自己的订单状态并支持取消。酒店运营方的诉求是:维护自己名下的酒店基本资料,管理房型可售数量,查看和处理入住订单。平台管理员的诉求则是:审核新注册的酒店是否真实有效,管理所有用户的账号状态,查看区域维度的订单统计和房间售卖情况。
三者的权限边界要在初期就设计好,最简单的做法是用一个role_type字段区分:0是用户、1是酒店端、2是平台管理员。后端的接口通过 Spring Boot 拦截器拦截请求,校验 JWT Token 里携带的角色信息,再决定是否放行。这样既避免了给每个模块写大量重复鉴权代码,也能在答辩时讲清楚“我是如何设计系统权限的”。
1.2 区域与酒店的数据模型建议
“区域”是这个系统的灵魂。最合适的建模不是在这几个酒店表里塞一个“所在城市”字符串,而是把区域信息单独设计成一张树形表,用父子关系表达大区、城市、商圈或者平台自定义的区域层级。表结构可以简化成:region(id, parent_id, name, region_code, sort_no)。每个区域有一个不重复的region_code,酒店表里只存这个编码,需要关联城市或大区时,通过编码前缀去匹配。
酒店和房型是最核心的基础数据,一个酒店下有多个房型,一个房型对应多天的库存。我建议至少设计这几张核心表:hotel酒店表、room_type房型表、room_inventory每日库存表、book_order订单表、order_status_log订单状态变更表。
很多人会把库存直接设计成total_number和booked_number两个字段放在房型表里,第一版跑 demo 没问题,一旦涉及“按入住日期搜房”,就会发现这个设计根本无法回答“3月5日到3月7日还有几间大床房”。因为库存是按自然日分开的,每间房每天是否被占用是独立事件。所以我会把“每日库存”独立出来,用room_type_id + biz_date作为唯一键,每天一行记录。这个数据模型看着多一张表,但它才是预订系统的正确底座。
2. Maven 工程搭建与依赖管理:骨架稳了后面才顺手
确定了模块和表结构之后,开始搭工程。我建议直接使用 Maven 方式构建 Spring Boot 项目,这不仅是当前主流的工程组织方式,也方便后续把项目拆成通用模块。
2.1 为什么用 Maven 而不是直接下载 jar 包
有些刚接触 Spring Boot 的同学会习惯去官网把依赖的 jar 包一个个下载到本地,再手动导入 IDE。这个做法在小 demo 里能跑,但放到真实的项目里会出现两个问题:一是 jar 包版本之间容易产生冲突,二是换一台电脑后整个环境要重来一遍。Maven 之所以是标准做法,是因为它通过pom.xml统一管理依赖的坐标和版本号,并且具备依赖传递特性,我引入一个spring-boot-starter-web,它自动把 Web 环境相关的依赖一并引入,不需要我关心内部版本。
创建项目的方式有两种,一种是通过 Spring Initializr 网站生成压缩包,再导入 IDE;另一种是直接在 IDEA 的 New Project 里选择 Spring Initializr 生成。无论哪种方式,最终得到的都是一个带pom.xml的标准 Maven 工程。
2.2 核心依赖清单与配置红线
我建议的 Spring Boot 版本选择策略是:如果本机使用 JDK 8,选 Spring Boot 2.7.x;如果已经是 JDK 17+,可以直接使用 Spring Boot 3.x。下面这份依赖清单是从实际项目里整理出来的核心配置,可以直接抄作业:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> </dependencies>数据库和 Redis 的配置在application.yml里,以下是我固定使用的配置模板:
server: port: 8080 spring: application: name: hotel-region-system datasource: url: jdbc:mysql://localhost:3306/hotel_region?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 data: redis: host: localhost port: 6379 database: 0 mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl management: endpoints: web: exposure: include: health,info,metrics,prometheus endpoint: health: show-details: always这里有两个细节值得单独说。第一,数据库连接串里必须写serverTimezone=Asia/Shanghai,否则 JDBC 驱动会读取系统时区,容易造成时间的八小时偏差。第二,MyBatis-Plus 的驼峰映射和逻辑删除配置提前打开,后面所有实体都不用为“下划线字段转驼峰”发愁,删除操作也会从物理删除变成逻辑删除,对毕设项目和复盘案例来说更安全。
2.3 可观测性从一开始就埋好
Spring Boot 项目自带的监控能力经常被忽略,直到我在联调时为了确认接口到底有没有被调用成功,反复在代码里加日志才后悔当初没接入 Actuator。配合 Micrometer 之后,Actuator 可以输出标准格式的指标,后端代码里也能方便地埋点统计。
实践上我会启动服务之后先访问/actuator/health,确认服务健康状态,再通过/actuator/prometheus查看 JVM 指标和 HTTP 请求统计。如果只想快速验证接口是否被打到了,可以在拦截器里添加如下逻辑:利用 Micrometer 的MeterRegistry注册一个自定义计数器,按接口路径统计请求数,这是演示项目里很加分的亮点:
@Component public class ApiMetricInterceptor implements HandlerInterceptor { private final MeterRegistry meterRegistry; public ApiMetricInterceptor(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { Counter counter = Counter.builder("hotel.api.request.total") .tag("uri", request.getRequestURI()) .register(meterRegistry); counter.increment(); return true; } }这套做法的好处是:答辩演示时说“我的系统有健康检查和实时指标输出”,不是只停留在概念层面,而是真的能看到端点返回的 JSON 数据流。
3. 预订主链路实现:库存怎么扣、订单怎么防重
前面准备得再完善,最终系统好不好用还是要看核心链路。在住宿系统里,最核心的就是“库存查询 → 创建订单 → 扣减库存”这一整条链路,这个环节也最容易翻车。
3.1 按日期扣库存表设计
库存表的核心字段我建议这样做:
CREATE TABLE `room_inventory` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `room_type_id` BIGINT NOT NULL COMMENT '房型ID', `biz_date` DATE NOT NULL COMMENT '业务日期', `total_stock` INT NOT NULL DEFAULT 0 COMMENT '当天总可售数', `booked_stock` INT NOT NULL DEFAULT 0 COMMENT '当天已预订数', PRIMARY KEY (`id`), UNIQUE KEY `uk_room_date` (`room_type_id`, `biz_date`) ) COMMENT '房型每日库存表';查询某区域某段时间的可售房型时,第一步是先按区域或酒店过滤房型,第二步再关联room_inventory,统计该房型在这段入住日期内是否每一天都还有剩余可售库存。SQL 大概长这样:
SELECT rt.id AS roomTypeId, rt.hotel_id, rt.name, rt.price, rt.area FROM room_type rt WHERE rt.hotel_id IN (SELECT id FROM hotel WHERE region_code = #{regionCode}) AND rt.id NOT IN ( SELECT ri.room_type_id FROM room_inventory ri WHERE ri.biz_date BETWEEN #{checkInDate} AND #{checkOutDateMinusOne} AND ri.booked_stock >= ri.total_stock )这里为了演示把重点放在库存上,所以 SQL 写得比较直观。第二层子查询的本质逻辑是:如果这个房型在入住期间的任何一晚已经卖满,那么整个入住区间都不可订。这样做表面上是放弃了一次预订多天但某天满房的灵活性,实际对酒店前台业务来说是完全正确的,因为用户既然选择了这个区间,就不该被迫中途换房。
3.2 生成订单的代码写法
当用户在前端点击“提交订单”时,后端接口要做三件事:生成业务单号、逐日扣减库存、插入订单记录并创建状态日志。其中逐日扣减库存是并发控制的关键,必须使用原子 SQL,而不是先查询再判断再更新。因为我先查询出来的库存数据,在两个请求同时到达时是旧数据,会造成超卖。
扣减库存的 Mapper 方法如下:
@Update("UPDATE room_inventory SET booked_stock = booked_stock + 1 " + "WHERE room_type_id = #{roomTypeId} AND biz_date = #{bizDate} " + "AND booked_stock < total_stock") int deductStock(@Param("roomTypeId") Long roomTypeId, @Param("bizDate") LocalDate bizDate);这个 SQL 把判断库存和扣减合并成了一步,数据库的行锁会保证同一时间只能有一个请求成功修改这一天的记录。真正执行成功的行数是 1,否则说明这一晚已经没有余量了。
订单创建部分要开启事务,保证所有天的库存都扣成功才插入主订单,否则回滚:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateRequest request) { Long userId = UserContext.getUserId(); LocalDate checkIn = request.getCheckInDate(); LocalDate checkOut = request.getCheckOutDate(); List<LocalDate> nights = createNightList(checkIn, checkOut); for (LocalDate night : nights) { int affected = inventoryMapper.deductStock(request.getRoomTypeId(), night); if (affected == 0) { throw new BizException("所选日期房间库存不足"); } } String orderNo = generateOrderNo(); BookOrder order = new BookOrder(); order.setOrderNo(orderNo); order.setUserId(userId); order.setHotelId(request.getHotelId()); order.setRoomTypeId(request.getRoomTypeId()); order.setCheckInDate(checkIn); order.setCheckOutDate(checkOut); order.setOrderAmount(request.getOrderAmount()); order.setStatus(OrderStatusEnum.PENDING_PAY.getCode()); orderMapper.insert(order); OrderStatusLog log = new OrderStatusLog(); log.setOrderId(order.getId()); log.setFromStatus(0); log.setToStatus(OrderStatusEnum.PENDING_PAY.getCode()); orderStatusLogMapper.insert(log); return OrderConverter.toVO(order); }很多初学者容易忽略,事务自调用是个大坑。我在实际指导项目时发现,有同学把createOrder和deductStock都写在一个 Service 类里,它们之间是同类方法直接调用,即使某个异常抛出来,事务如果依赖 Spring AOP 的代理机制,也可能不会正常回滚。为了稳妥,我习惯把库存扣减的 Mapper 操作明确放在一个独立 Service 里,或者确保事务方法的调用入口来自 Controller 或其他 Spring Bean。这样,实际执行失败时数据库会回滚到没有任何库存变化的状态,不会出现用户交了钱但房间根本没锁定住的严重问题。
4. Redis Stream 在订单通知里的落地
订单创建完成之后,通常需要通知酒店端“你有新订单了”。如果直接在创建订单的业务代码里同步发送通知,某次通知服务出问题或响应变慢时,整个下单接口都会被拖住。这里我选择用 Redis Stream 作为轻量级消息队列的方案,来解决下单和通知之间的异步解耦问题。
4.1 为什么这次选 Redis Stream 而不是 RabbitMQ
RabbitMQ 确实很成熟,但在这个毕设场景里引入一套独立的 MQ 中间件,意味着答辩现场的环境会多一个部署依赖。而 Redis 本来就已经承担了登录 Token 缓存和热门酒店数据的缓存职责,Redis Stream 又是 Redis 5.0 之后自带的消息队列能力,不需要额外安装新东西。
Redis Stream 的模型和消费者组概念对面试官来说也很有说服力。它可以把消息写入 Stream,里面持久地记录每条消息的 ID,一个消费者组里的多个消费者可以分工消费同一条 Stream,消费完成后调用XACK确认消息。这与 RabbitMQ 的队列确认机制在思想上很接近,但实现成本低得多。
下单接口推送消息的代码大致是这样:
@Autowired private StringRedisTemplate stringRedisTemplate; public void pushOrderCreatedEvent(OrderCreatedEvent event) { Map<String, String> body = new HashMap<>(); body.put("orderNo", event.getOrderNo()); body.put("hotelId", String.valueOf(event.getHotelId())); body.put("message", "您有新的入住订单,请及时处理"); stringRedisTemplate.opsForStream().add( StreamRecords.newRecord().ofObject(body).withStreamKey("stream:hotel-order") ); }注意,这里不能直接把event对象塞进 Redis,因为 StringRedisTemplate 默认使用字符串序列化器,所以我在推送前把事件对象转成了一个Map<String, String>。
4.2 消费者端怎么拉取消息并确认
使用 Spring Data Redis 的StreamMessageListenerContainer可以很方便地实现监听。下面的代码展示了一个比较稳妥的消费者写法:
@Component public class OrderEventConsumer implements ApplicationRunner { @Resource private RedisConnectionFactory redisConnectionFactory; @Resource private StringRedisTemplate stringRedisTemplate; @Override public void run(ApplicationArguments args) { StreamMessageListenerContainer<String, MapRecord<String, String, String>> container = StreamMessageListenerContainer.create(redisConnectionFactory, StreamMessageListenerContainerOptions.builder() .pollTimeout(Duration.ofSeconds(1)) .build()); container.receive( Consumer.from("order-group", "consumer-1"), StreamOffset.create("stream:hotel-order", ReadOffset.lastConsumed()), this::handleOrderMessage ); container.start(); } private void handleOrderMessage(MapRecord<String, String, String> message) { try { String orderNo = message.getValue().get("orderNo"); // 模拟给酒店端发送站内信或短信通知 log.info("处理新订单消息: {}", orderNo); stringRedisTemplate.opsForStream().ack( "stream:hotel-order", "order-group", message.getId() ); } catch (Exception ex) { log.error("处理订单消息失败", ex); // 不执行 ack,等待后续重新消费 } } }使用ReadOffset.lastConsumed()而不是ReadOffset.from("0")是一个常见细节。如果消费者把消息消费了但没来得及ack,lastConsumed()会从上次未确认的消息开始继续拉取,保证消息不丢失。当然,要真正达到“不丢失”的效果,生产端在发送前也应该考虑 Redis 所在实例是不是存在持久化配置,不过在毕设演示场景下,上面这层逻辑已经足以体现出对消息可靠性的思考了。
5. 常见问题与排查技巧实录
这部分内容是我在这个项目上最有价值的沉淀。项目开发到后期,遇到的问题往往不是功能不会写,而是环境配置或隐蔽数据处理逻辑出了问题。
5.1 启动失败类问题
最典型的是 Spring Boot 项目一启动就报Failed to configure a DataSource。原因基本可以锁定为application.yml里的数据源配置没有被正确加载,或者依赖了spring-boot-starter-jdbc但没有配置数据库地址。排查思路是先去target/classes目录下确认 yml 文件是否真的被编译到了输出目录,再去检查@SpringBootApplication扫描的包路径和启动类的位置是否一致。
另一个高频错误是页面接口返回 404,但后台日志没有任何异常。我会先检查 Controller 类是否被 Spring 容器扫描到,主要看@RestController注解有没有写正确,再看请求路径和方法上声明的映射是否一致。这里有个小习惯:尽量把启动类放在顶层包,Controller、Service、Mapper 都放在它的子包下,这样可以避免手动配置@ComponentScan引发遗漏。
5.2 数据处理与事务类问题
库存数据不正确是最难排查的一类问题。如果自己用测试工具并发下两单,最后发现超卖了,优先检查deductStock的 SQL 是否带上了booked_stock < total_stock条件。没有这个条件的更新语句永远都能执行成功,相当于库存控制名存实亡。
还有一类隐蔽问题是订单取消后库存恢复,但没有重新判断状态流。比如用户在下单支付后取消了订单,接口执行了退订,但由于用户连续点了两次取消按钮,库存可能被恢复两次。解决手段是取消操作要带上状态条件更新,例如:
@Update("UPDATE book_order SET status = #{targetStatus}, cancel_time = NOW() " + "WHERE id = #{orderId} AND status = #{expectStatus}") int cancelOrder(@Param("orderId") Long orderId, @Param("expectStatus") Integer expectStatus, @Param("targetStatus") Integer targetStatus);只有当expectStatus和当前订单状态一致时,update 才返回 1,后一次点击取消会返回 0,库存就不会被恢复两次了。
5.3 面试和答辩时怎么把项目讲深
这个项目做完了,不可避免会被问到 Spring Boot 相关问题。我梳理了几个在答辩中大概率会被追问的问题。
第一个问题是@SpringBootApplication是什么?它其实是一个组合注解,内部包含了@SpringBootConfiguration、@EnableAutoConfiguration和@ComponentScan。@EnableAutoConfiguration是自动配置的核心,它会去读取spring.factories中的自动配置类,再根据当前 classpath 下的依赖条件决定是否装配对应的组件。很多人只会背结论,但如果能在回答时结合自己的项目说:“因为我的pom.xml里引入了 Redis 的 starter,Spring Boot 会在检测到对应类之后自动生成 RedisTemplate 相关的 Bean”,效果就完全不同。
第二个问题是@Transactional在什么情况下不会回滚。默认情况下只有 RuntimeException 会导致回滚,而受检异常不会自动回滚。我在扣库存场景里特意使用rollbackFor = Exception.class,就是为了把数据库操作异常和业务异常都纳入回滚范围。同时还要提防自调用问题、异常被 try-catch 吞掉的问题,这两点在项目代码里最好主动规避并形成注释。
第三个问题是 Redis Stream 和普通 List 队列有什么区别。Stream 支持消费者组、消息 ACK、阻塞读取,消息不会被简单的LPOP取而丢;而使用 List 做队列时,如果消费者拿到数据后服务宕机,这条消息就会丢失。从这里就能自然引申你设计的订单通知消费者为何使用lastConsumed()模式和手动ack。
5.4 热门扩展:移动端通知推送
项目如果希望扩展成“移动端 + 后端”的完整形态,可以接入基于 REST API 的推送服务。如果你熟悉的客户端技术栈是 Android,使用 Firebase Cloud Messaging 和 Spring Boot 集成是一个标准方案:先在项目中添加firebase-admin依赖,然后通过服务账号 JSON 文件初始化 SDK,最终调用接口向设备注册的 token 发送通知。
核心代码大致如下:
FileInputStream serviceAccount = new FileInputStream("path/to/firebase-service-account.json"); FirebaseOptions options = FirebaseOptions.builder() .setCredentials(GoogleCredentials.fromStream(serviceAccount)) .build(); FirebaseApp.initializeApp(options); Message message = Message.builder() .setToken(clientToken) .putData("type", "NEW_ORDER") .setNotification(Notification.builder() .setTitle("您有新的酒店订单") .setBody("请及时登录后台处理") .build()) .build(); FirebaseMessaging.getInstance().sendAsync(message);对于只在 PC 端演示的毕设,也可以把这一步简化成调用企业微信机器人或邮件接口,但整体思路是一样的:服务端只负责把消息可靠地交给第三方推送通道,具体的展示逻辑留给终端完成。这一整块扩展能力在答辩时属于“亮点功能”,建议放在整体流程图里串讲。
6. 项目上线前的自检清单
当功能写得差不多时,会比写代码阶段还容易出乱子。我接手过不少毕设项目,代码本身能用,但交到答辩现场装环境时就原形毕露。下面这份自检清单是我每次交付项目前都会逐一过一遍的,建议直接截图保存。
- 确认 MySQL、Redis 服务是否在本地开机自启,给出明确的启动顺序说明。
- 检查数据库初始化脚本是否完整,包括建库语句、建表语句、初始化测试数据,新增的测试账号要能稳定登录。
- 启动项目后先访问一次
/actuator/health,确认状态为UP后再进入页面测试。 - 把下单接口用工具并发请求压 10 次,观察订单表和库存表数据是否一致。
- 检查所有接口返回格式是否统一,建议封装统一的
Result<T>响应对象,不要把异常堆栈直接抛给前端。 - 检查前端页面是否还有
console.log或假数据写死的情况,上线前把 mock 接口地址切换成真实后端地址。 - 密码绝不能明文存储,至少使用 BCrypt 加密;如果使用了 Spring Security,把默认的登录页替换成自己的登录接口。
最后再分享一个我从个人实践里总结出来的经验:这类系统真正拉开差距的地方通常不在稀奇古怪的技术框架,而是在业务约束的严谨程度。你处理了订单超卖、库存错乱、重复取消、消息丢失这类基础问题之后,项目从“能演示”一下子就会提升到“能上线”的水准。答辩老师问你“这个系统有什么难点”时,你能从容说出几条带着代码细节的解决过程,就远比背十份理论讲义更有说服力。