
“区域酒店住宿信息系统”这类毕设标题乍看就是一套普通的 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency dependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-prometheus/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency /dependencies数据库和 Redis 的配置在application.yml里以下是我固定使用的配置模板server: port: 8080 spring: application: name: hotel-region-system datasource: url: jdbc:mysql://localhost:3306/hotel_region?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse 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这里有两个细节值得单独说。第一数据库连接串里必须写serverTimezoneAsia/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(); ListLocalDate 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 而不是 RabbitMQRabbitMQ 确实很成熟但在这个毕设场景里引入一套独立的 MQ 中间件意味着答辩现场的环境会多一个部署依赖。而 Redis 本来就已经承担了登录 Token 缓存和热门酒店数据的缓存职责Redis Stream 又是 Redis 5.0 之后自带的消息队列能力不需要额外安装新东西。Redis Stream 的模型和消费者组概念对面试官来说也很有说服力。它可以把消息写入 Stream里面持久地记录每条消息的 ID一个消费者组里的多个消费者可以分工消费同一条 Stream消费完成后调用XACK确认消息。这与 RabbitMQ 的队列确认机制在思想上很接近但实现成本低得多。下单接口推送消息的代码大致是这样Autowired private StringRedisTemplate stringRedisTemplate; public void pushOrderCreatedEvent(OrderCreatedEvent event) { MapString, 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 默认使用字符串序列化器所以我在推送前把事件对象转成了一个MapString, 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) { StreamMessageListenerContainerString, MapRecordString, 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(MapRecordString, 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)是一个常见细节。如果消费者把消息消费了但没来得及acklastConsumed()会从上次未确认的消息开始继续拉取保证消息不丢失。当然要真正达到“不丢失”的效果生产端在发送前也应该考虑 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 的 starterSpring 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 次观察订单表和库存表数据是否一致。检查所有接口返回格式是否统一建议封装统一的ResultT响应对象不要把异常堆栈直接抛给前端。检查前端页面是否还有console.log或假数据写死的情况上线前把 mock 接口地址切换成真实后端地址。密码绝不能明文存储至少使用 BCrypt 加密如果使用了 Spring Security把默认的登录页替换成自己的登录接口。最后再分享一个我从个人实践里总结出来的经验这类系统真正拉开差距的地方通常不在稀奇古怪的技术框架而是在业务约束的严谨程度。你处理了订单超卖、库存错乱、重复取消、消息丢失这类基础问题之后项目从“能演示”一下子就会提升到“能上线”的水准。答辩老师问你“这个系统有什么难点”时你能从容说出几条带着代码细节的解决过程就远比背十份理论讲义更有说服力。