拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot民宿预订系统设计与实现:订单状态机与防超卖核心解析

民宿预定系统这个题目这几年在我接触的计算机毕业设计里出现频率非常高名下挂着“栖游智订”“乡舍云订”这类系统名网上搜出来一大片但真上手做的人都知道难的不是增删改查而是那些藏在业务细节里的决策——两个订单的日期到底怎么判断冲突、同一套房源被两个人同时下单怎么避免超卖、订单状态从“待支付”一路走到“已完成”要经过哪些校验稍不留神就会把逻辑写得一团乱麻。这篇文章我以基于 Java SpringBoot 的 Web 端民宿预订平台为核心从需求拆解、技术选型、数据库设计到核心下单链路、后台管理、部署上线把一整套设计和实现过程完整捋一遍。适合正在定题或者已经开工的计算机毕设同学也适合想把手头 SpringBoot 项目从“能跑”提升到“能讲明白”的初级开发者。1. 需求拆解一个民宿预订系统最该管好的三件事很多人在毕设开题阶段容易犯一个毛病——上来就画功能清单把“用户能登录、能订房、能支付、能评论”全部列一遍看起来功能挺全实际上等于什么都没想。做系统之前必须先回答一个问题这个平台里有哪几类人他们各自最在意的是什么。1.1 三类角色的真实诉求民宿预订系统和标准酒店预订系统有个明显区别酒店通常有几十上百间同标准客房民宿常常是一套房源一天只租给一波客人甚至整栋整院出租。这个业务特征决定了系统的核心矛盾不是“库存有多少”而是“指定日期段里这套房源到底空不空”。我把用户拆成三类住客前台用户搜索目标城市的房源、按日期和人数筛选、看房型和价格、下单支付、查看订单状态、入住后评价。住客最在意的就一句话我选的日期能不能订上别到时候告诉我没房。民宿主商家用户上架和管理房源、维护房型价格、查看订单、确认入住或退款。民宿主最烦的是重复核对订单最怕的是同一晚被订了两次。平台管理员后台用户审核民宿主入驻、管理所有用户和订单、看基础经营数据。管理员要的是能管住全局而不是替民宿主操作业务。这三类角色的诉求没有一条是孤立的功能它们全部汇聚到一张订单上。所以需求分析的落点不是画了多漂亮的功能树而是想清楚“订单是怎么被创建、被校验、被改变状态的”。1.2 核心业务流程到底长什么样用大白话走一遍完整流程住客打开网站 → 输入城市和入住离店日期 → 系统返回该时段内有空房的房源列表 → 住客选房下单 → 生成待支付订单 → 支付毕设里通常是模拟支付→ 民宿主看到已支付订单 → 到店入住 → 离店完成 → 住客评价。这个流程里藏着两个最容易翻车的设计点一个是“有空房”三个字怎么算出来另一个是“下单”这一刻如何保证不撞单。后面的章节我会专门展开讲这里先强调一个观念需求分析阶段就把这两个问题想清楚后面写代码会顺利非常多否则等你开始写 SQL 才发现表结构撑不起业务回头改表才是真噩梦。2. 技术选型与架构设计为什么是 SpringBoot而不是自己搭一套 SSM技术选型这块很多同学只会写一句“选用 Java SpringBoot 作为开发框架”答辩老师一问“为什么”就答不上来。这里我把旧方案和新方案摆出来对比一下你就知道该怎么说了。2.1 SpringBoot 相比传统 SSM 到底省了什么事SSMSpring SpringMVC MyBatis本身不复杂复杂的是配置。写一个项目要先配 web.xml、spring-mvc.xml、applicationContext.xml、mybatis-config.xml每个文件里还有一堆 bean 声明和扫描路径配置文件之间互相引用稍不注意就启动报错。SpringBoot 的核心理念是约定大于配置把传统整合中 80% 的固定配置全部用默认值代替你只要引入一个spring-boot-starter-web就能得到一个内嵌 Tomcat 的独立 Web 应用一个main方法启动不用再往 Tomcat 里丢 war 包。用刚才这个项目来说SpringBoot 带来的直接收益是依赖管理不用再手工处理版本冲突spring-boot-starter-parent统一管好了常用依赖的版本。数据库操作直接用spring-boot-starter-jdbc或 MyBatis 场景启动器配置集中在application.yml一个文件。统一异常处理、拦截器、静态资源映射等等都有现成的机制可以扩展。不过我要说句公道话毕设选题的时候别为了“显高级”盲目追求新版本。SpringBoot 3.x 目前比较活跃但它基于 Jakarta EE而且起步依赖最低要求 Java 17如果你本地还是 JDK 8老老实实用 SpringBoot 2.x 更省心。很多同学下载了网上最新教程结果项目创建完第一眼就是编译报错多半就是版本和 JDK 对不上。2.2 项目分层与包结构划分我用的是经典三层架构但不是教科书里那种死板的三层而是带了一点企业项目习惯的分层。大概结构如下com.qingyou ├── controller # 接口层只做参数接收和结果返回 ├── service # 业务层处理具体业务逻辑和事务 ├── mapper # 数据层对应 MyBatis 的 Mapper 接口 ├── entity # 数据库实体对象和表结构一一对应 ├── dto # 接收前端参数的传输对象 ├── vo # 返回给前端的结果对象 ├── config # 配置类如拦截器、静态资源映射 ├── common # 公共类如统一返回结果、异常处理 └── utils # 工具类如日期处理、订单号生成为什么要区分 DTO 和 VO而不是直接拿实体类给前端说实话毕设系统业务简单直接用实体也不是不行但有一个实际痛点实体类字段往往带着数据库的内部信息比如用户表里的密码字段如果直接把用户实体返回给前端密码就明文暴露了。我用 VO 把这个坑提前堵上也算答辩时一个可讲的亮点。接口返回我用一个统一结构public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ... } public static T ResultT error(String message) { ... } }统一返回结构的好处是前端处理数据的方式完全一致不用每个接口自己定义不同的包装格式。配合一个全局异常处理器业务里抛出异常后能自动转成{code:500, message:...}的 JSON前端弹个提示就行不用每个 Controller 都写 try-catch。3. 数据库设计订单状态机才是整个系统的灵魂数据库设计是“栖游智订”这个项目里我花时间最多的地方。民宿业务和普通电商不一样它的核心资源不是有明确库存数量的商品而是按“日期段”约束的房源所以表结构和查询逻辑都要围着“日期段”来做文章。3.1 核心表结构与字段取舍我的核心表一共六张用户表、民宿表、订单表、评价表外加辅助的收藏表和民宿图片表。重点看前面四张。用户表CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, phone VARCHAR(20) DEFAULT NULL, role TINYINT NOT NULL DEFAULT 1 COMMENT 1住客 2民宿主 3管理员, avatar VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里一个细节密码字段长度不要存 32 位因为如果你用了 BCrypt 加密生成的字符串是 60 位左右VARCHAR(50)会直接存不进去。这也是很多同学在登录时总报“密码错误”的隐蔽原因之一。民宿表我把它理解为“可预订的资源”也就是一套房源它本身带所在城市、地址、图片、设施标签并挂上民宿主 IDCREATE TABLE house ( id BIGINT NOT NULL AUTO_INCREMENT, owner_id BIGINT NOT NULL COMMENT 民宿主用户ID, name VARCHAR(100) NOT NULL COMMENT 房源名称, city VARCHAR(50) NOT NULL COMMENT 所在城市, address VARCHAR(255) DEFAULT NULL, price DECIMAL(10,2) NOT NULL COMMENT 每晚价格, room_count INT NOT NULL DEFAULT 1 COMMENT 同户型可售数量, max_guests INT NOT NULL DEFAULT 2 COMMENT 最多入住人数, cover VARCHAR(255) DEFAULT NULL COMMENT 封面图, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架 2待审核, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_city (city) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;room_count这个字段是给“一室一厅有多套”这种情况用的纯独栋民宿就填 1。加上它对后面库存判断的实现影响很大稳妥的做法是先想清楚自己项目里房源是“一家一晚只接一单”还是“同户型可以多单”再决定这个字段要不要。订单表CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, house_id BIGINT NOT NULL, check_in_date DATE NOT NULL COMMENT 入住日期, check_out_date DATE NOT NULL COMMENT 离店日期, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已入住 3已完成 4已取消 5已退款, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_house_date (house_id, check_in_date, check_out_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;3.2 日期段冲突的判定逻辑与索引设计这是整个项目最核心的查询逻辑。假设住客要订 8 月 1 日到 8 月 3 日离店的房源那么 8 月 1 日和 8 月 2 日这两晚房源必须空着。一个已存在的订单 B 只要满足“B 的入住日期 8 月 3 日 且 B 的离店日期 8 月 1 日”就说明 B 占用了这段时间。这个条件在代码里写成SELECT COUNT(*) FROM orders WHERE house_id #{houseId} AND status IN (0, 1, 2) AND check_in_date #{checkOutDate} AND check_out_date #{checkInDate}这段 SQL 咋一看像绕口令你可以用两个区间对比来记区间 A 是[8/1, 8/3)区间 B 是订单的[b.start, b.end)A 和 B 有交集的条件就是A.start B.end AND B.start A.end。我把入住日期当作闭区间起点、离店日期当作开区间终点这样“8 月 1 日入住、8 月 2 日离店”这类刚好相邻的订单不会互相误伤。这个查询还隐含了状态过滤只有待支付、已支付、已入住这三种状态的订单才算占用房源被取消和已退款的订单不算。很多同学写到这里会把状态过滤漏掉导致用户一退单日期还是被锁着这种 bug 在测试阶段才暴露的话特别抓狂。3.3 订单状态机的设计细节我把订单状态定义为五个常量值并在代码里用枚举管理public enum OrderStatus { PENDING(0, 待支付), PAID(1, 已支付), CHECKED_IN(2, 已入住), COMPLETED(3, 已完成), CANCELLED(4, 已取消), REFUNDED(5, 已退款); }状态机最需要注意的是“哪些转换是合法的”。比如待支付订单可以取消也可以支付已支付订单可以入住也可以发起退款但已完成订单绝对不能直接改成待支付。实现上有两种思路一种是对每个状态转换都写 if 判断状态多了很乱另一种是定义一个状态转换表把合法的转换路径集中管理。毕设项目用前者完全够但用后者在答辩时更容易体现出工程思维。我实际用的是前者但把判断逻辑抽成了一个私有的checkStatusTransition方法所有修改状态的入口都过这个方法保证不会出现“数据库里订单状态直接乱跳”的尴尬情况。4. 核心业务链路实现从房源检索到订单闭环需求、表结构都清楚了接下来就是我实际编码时最有体感的部分——真正的业务链路。这一节我会把搜索、下单、并发控制、模拟支付完整展开。4.1 房源检索与“有空房”的实现住客搜索时输入城市和日期段系统要做两件事第一按城市过滤房源第二排除那些在所选日期段内已有有效订单的房源。第一条很简单第二条需要把上一节的冲突判断语句套进去SELECT * FROM house h WHERE h.city #{city} AND h.status 1 AND NOT EXISTS ( SELECT 1 FROM orders o WHERE o.house_id h.id AND o.status IN (0, 1, 2) AND o.check_in_date #{checkOutDate} AND o.check_out_date #{checkInDate} )注意这里我用的是NOT EXISTS而不是NOT IN。从结果上看两者很像但NOT IN遇到子查询返回 NULL 时整个查询会异常地不返回任何数据这是一个非常经典的线上事故点。虽然我们的子查询不会出现 NULL 这个具体列但用NOT EXISTS能从根本上避开这个语义陷阱查询优化器处理起来也更友好。如果需要支持同户型多间的场景上面的写法就要稍微改一下用“总和”来判断SELECT h.*, h.room_count - IFNULL(b.booked, 0) AS remain FROM house h LEFT JOIN ( SELECT house_id, COUNT(*) AS booked FROM orders WHERE status IN (0, 1, 2) AND check_in_date #{checkOutDate} AND check_out_date #{checkInDate} GROUP BY house_id ) b ON h.id b.house_id WHERE h.city #{city} HAVING remain 0这里有个小坑HAVING不能直接引用SELECT里的别名来做WHERE过滤但HAVING remain 0是合法写法。个人经验是如果你的毕设需求里没有“同户型多间同时可订”这个场景直接用第一个版本的NOT EXISTS逻辑更清晰答辩也更好讲。4.2 下单接口与并发防超卖这是整个系统里最值得在答辩时重点讲的一段逻辑。先看核心思路用户点击“提交订单”后后端不能只做一次库存判断就完事因为两个用户同时下单时两次查询都可能看到“无冲突”但都插入后就超卖了。我的处理方式是在事务里先对房源行加悲观锁再查冲突再插入订单。对应 MyBatis 的做法是 select 时带上FOR UPDATETransactional public Long createOrder(OrderDTO dto) { House house houseMapper.selectByIdForUpdate(dto.getHouseId()); if (house null) { throw new BizException(房源不存在); } // 校验房源状态 if (house.getStatus() ! 1) { throw new BizException(房源已下架); } // 日期合法性校验 if (!dto.getCheckOutDate().isAfter(dto.getCheckInDate())) { throw new BizException(离店日期必须晚于入住日期); } // 再查一次冲突订单 int conflict orderMapper.countConflictOrders(house.getId(), dto.getCheckInDate(), dto.getCheckOutDate()); if (conflict 0) { throw new BizException(该房源在所选日期已被预订); } // 生成订单初始状态待支付 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setHouseId(house.getId()); order.setCheckInDate(dto.getCheckInDate()); order.setCheckOutDate(dto.getCheckOutDate()); long days ChronoUnit.DAYS.between(dto.getCheckInDate(), dto.getCheckOutDate()); order.setTotalPrice(house.getPrice().multiply(BigDecimal.valueOf(days))); order.setStatus(OrderStatus.PENDING.getCode()); orderMapper.insert(order); return order.getId(); }selectByIdForUpdate会锁住这一行房源记录另一个事务的同类查询会阻塞直到第一个事务提交或回滚这样就把“两个人都查到没冲突”的窗口期堵死了。4.3 模拟支付与订单状态推进毕设做真实支付不现实也没有必要。最常用的做法是把支付做成沙箱模式页面展示一个“去支付”按钮点击后调用后端的支付接口后端内部直接模拟支付成功并更新状态。如果你想保留一点真实感可以接入支付宝沙箱但申请商家账号、配置密钥、验证回调这些环节会消耗不少时间。我的建议是核心链路用模拟支付然后明确告诉答辩老师“真实支付需要商家资质项目里用沙箱模式替代”。这不算减分项反而说明你清楚真实场景的边界在哪里。模拟支付后的状态推进顺序是待支付 → 已支付 → 已入住 → 已完成。民宿主端可以对已支付订单执行“确认入住”住客端可以对已入住订单执行“确认离店并完成”每个动作后台都校验当前状态允许哪种转换。这些转换的代码不复杂但很容易在细节上出错比如忘记更新pay_time或者状态改完之后没有同步刷新页面数据。4.4 订单编号生成的那点事订单号看似不起眼但我见过不少同学直接拿数据库自增 ID 当订单号给用户看这种做法在毕设里勉强能过可放到真实场景完全不行——订单号泄露交易量、自增 ID 容易被遍历抓取数据。我用的是时间戳 用户ID后四位 随机数拼出来的 20 位以内的字符串虽然没有全局唯一分布式 ID 那么严谨但应付单机项目绰绰有余。你也可以用LocalDateTime加UUID截断来生成只要保证唯一即可。5. 后台管理民宿主和管理员的差异化设计后台管理的功能我分成了两端民宿主端和管理员端。两者看起来都是管理页面但思维模型完全不同。5.1 民宿主端的房源与订单管理民宿主端的核心操作是“管房源”和“管订单”。管房源就是增删改查自己的房源信息这里要设计一个保姆级的细节民宿主只能操作owner_id等于自己 ID 的房源。很多同学容易漏掉这个条件导致一个民宿主能改别人的房源这个 bug 在答辩演示时被发现会非常尴尬。我的做法是在 Mapper 的所有房源操作方法上都带上ownerId作为查询条件Update(UPDATE house SET name #{name}, price #{price} WHERE id #{id} AND owner_id #{ownerId}) int updateOwnHouse(House house);管订单这块民宿主需要对已支付订单执行“确认入住”。这里有一个容易忽略的点民宿主确认民宿到店时最好顺带做一次“当前时间是否在入住日前后”的宽松校验而不是严格卡死在入住当天因为在真实业务里提前到店、晚到店都很常见。毕设项目可以放宽校验但要在代码注释里说明这个业务考量答辩时老师一听就知道你真想过业务细节。5.2 管理员端的审核与统计管理员端承担两类职责审核民宿主入驻申请、维护平台基本数据。民宿主注册后不能立刻上架房源先进入“待审核”状态管理员审核通过后才能正常营业。这个规则看起来平白无故多了一个环节但它很真实——几乎所有内容平台都有类似的准入机制而且它让系统里多了一张“审核状态”的链路功能层次就丰富了。统计模块最简单的落地方式是总用户数、总房源数、总订单量、近 7 天订单量趋势、房态热卖榜。用 JFreeChart 或者 ECharts 都行。我个人推荐 ECharts因为图表好看、交互顺手前端引入一个 CDN 就能用写一个柱状图只需要几行配置。统计 SQL 也不用写复杂的报表几个COUNT加GROUP BY就能覆盖毕设答辩里绝大多数问题。6. 实际开发中的常见坑与排查思路这部分是我最想写给后人的内容因为踩坑的体验永远是文档里学不到的。我把开发过程中最折磨人的几个问题按排查链路整理出来你要是碰上了可以直接照着排查。6.1 日期序列化格式不一致前后端联调时订单列表里的日期经常出现两种格式前端显示“Aug 01, 2025”而后台数据库存的是“2025-08-01”。这个问题的根源是 SpringBoot 默认用 Java 序列化LocalDateTime输出格式和前端预期不一致。我的修法是在application.yml里统一配置 Json 序列化格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这里还有一个坑MySQL 连接串里如果不带serverTimezone高版本的 MySQL 驱动会直接抛时区异常。我习惯在 JDBC URL 后面加上?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8配好一次后面不会再烦。6.2 图片上传后页面无法显示民宿上架需要传图片开发时很多人把图片保存到本地磁盘路径比如D:/upload/结果前端访问http://localhost:8080/upload/xxx.jpg返回 404。原因很简单保存路径和 Tomcat 对外提供静态资源的路径并不是一回事。SpringBoot 里正确姿势是配置一个资源映射器Configuration public class WebMvcConfig implements WebMvcConfigurer { Value(${upload.path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadPath); } }然后上传接口把文件写到uploadPath目录里前端图片地址直接拼/upload/文件名就能访问。这个思路解析起来很简单但没踩过坑的人真想不到addResourceHandler还可以映射本地磁盘路径。6.3 登录状态失效与权限绕过我最初做登录拦截时用的是一种“登录后把用户 ID 存到 session”的方案拦截器判断 session 里有没有用户就能挡住未登录访问。但后来发现一个细节问题前端页面通过 AJAX 请求接口时如果 session 过期后端返回的是 302 重定向前端拿到的响应是 HTML 页面而不是 JSON页面表现就是“莫名其妙的跳转或空白”。要解决这个问题我在拦截器里做了区分请求头里带了X-Requested-With: XMLHttpRequest的请求直接返回 JSON 格式的未登录信息普通页面请求才做重定向。这是一个非常贴近真实前端开发的细节写完这个功能你再回头看网上那些“SpringBoot 拦截器登录校验”的文章会觉得他们只讲了一半。6.4 时间区间的“占房校验”被绕过我在测试时还发现一个隐蔽问题用户在创建待支付订单后不支付直接关掉页面这笔订单会一直占着房源。要解决正常做法是加定时任务把超过 30 分钟未支付的订单自动置为已取消。SpringBoot 里可以用Scheduled注解实现一个定时任务配合EnableScheduling开启调度。这个功能工作量不大但让系统逻辑闭环完整度提高了不少值得做。7. 部署上线与毕设答辩的实操要点最后聊聊从“本地能跑”到“真正能交付”这段路以及答辩时怎么把项目讲出亮点。7.1 打包部署与常见启动失败SpringBoot 项目打包非常简单Maven 执行clean package后生成一个可执行 jar。本地验证只需要java -jar xxxx.jar就能启动。放到云服务器上时我习惯配合 Nginx 做反向代理Nginx 监听 80 端口把/api/前缀的请求转发到本地 8080 端口的 SpringBoot 服务。有一个特别经典的启动失败场景项目先在内网开发环境完全正常部署到服务器后启动时报“数据库连接失败”。排查思路一般按这三步走第一检查application.yml里的数据库地址是否是服务器能访问的内网地址第二确认 MySQL 的bind-address配置是否允许远程访问第三看安全组的 3306 端口是否放通。90% 的情况都出在这三个地方。7.2 答辩演示顺序和讲解重点毕设答辩的演示环节我的建议是你自己预设一条“业务主链”来演示而不是把所有页面点一遍。最佳顺序是注册一个民宿主账号 → 提交入驻申请 → 管理员审核通过 → 民宿主上架房源 → 切到普通用户账号搜索并订这间房 → 模拟支付 → 切回民宿主账号确认入住 → 用户端确认离店 → 发表评价 → 管理员端看统计数据。这条链路走完系统所有核心功能几乎全部覆盖而且故事性非常强评委不用费劲理解你这个系统能干什么。讲到核心代码时一定要聚焦“日期冲突判断”和“并发防超卖”这两块因为它们是整个系统里最有技术含量、最能体现你思辨能力的部分。ER 图和数据库设计也要能随手在白板上画出来评委问到时不要只是指着 PPT 念表名。还有一个小细节如果项目标题里带“栖游智订”“乡舍云订”这类系统名说明你在作品包装上是花了心思的答辩 PPT 里把系统名解释清楚比如“栖游”呼应民宿居住场景会显得整个项目完整度很高。这套民宿预订系统做下来我最深的体会有两点。第一毕设项目的价值不在于功能数量而在于你能把核心业务逻辑讲明白特别是那些容易出错的状态判断和并发场景这恰恰是生产系统最看重的部分。第二遇到问题先用日志定位再查文档最后才是问别人。比如日期序列化的问题只要打开接口返回的 JSON 对比一眼就发现是格式问题根本不用到处复制代码。把“排查问题的路径”练熟了不管是答辩还是工作面试你都能拿出真实项目经验来兜底。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门