SpringBoot做客房租赁平台,老生常谈又绕不开。我前前后后帮人评审过不下十个类似项目,大家踩的坑高度重合:角色权限糊成一团、订单状态靠改字段硬撑、库存扣减直接变负数、后端代码全堆在Controller里。这篇不是教科书式的“需求分析+技术选型”流水账,而是从动手做的角度,把这类平台的架构思路、核心模块落地方式、以及那些文档里不会写的坑一次性讲清楚。适合正在做课程设计的人、准备把这类型项目写进简历的求职者,以及想搞懂业务闭环到底怎么串起来的后端开发者。
1. 项目从0到1:客房租赁平台到底在解决什么问题
1.1 三类用户角色与核心需求拆解
很多新手拿到这个题目第一反应是“不就是CRUD吗”,真上手才发现,连角色都理不清。客房租赁平台至少得有三类角色,缺一个都跑不顺:
- 租客:注册登录、浏览房源、按城市和日期搜索、提交订单、在线支付或模拟支付、取消订单、入住后评价。
- 房东:维护房源基本信息、管理房源上下架、查看自己房源的订单、确认入住和退房。
- 管理员:审核房东提交的房源、管理用户状态、监控订单、看基础统计数据。
这三类角色对应的权限边界必须从一开始就分开。最典型的反面教材是只用一张user表加一个role字段,接口里全靠前端传参判断“你是不是管理员”,后端接口裸奔。这种写法Demo阶段能用,但只要功能稍微一多,就会出现租客直接调管理员接口改数据的笑话。
建议在建表阶段就把权限模型放进去,后端每个接口都校验角色,至少用拦截器把需要登录的路径保护起来。别觉得这是浪费时间,后面接订单、审核这些业务时,没有权限隔离你会寸步难行。
1.2 功能清单与模块划分思路
完整的客房租赁平台功能很多,但我不建议一上来就追求大而全。按业务闭环来排优先级,把最核心的链路先串通,再谈加分项:
- 第一优先级:注册登录、房源发布与列表展示、按条件搜索、提交订单、模拟支付、订单状态流转。
- 第二优先级:房东端订单确认、管理端房源审核、用户评论。
- 第三优先级:数据统计、消息通知、收藏、优惠券这类锦上添花的功能。
模块划分上,我习惯按领域拆分,而不是按页面拆分。也就是说,不要建一个“租房管理Controller”把所有接口都塞进去,而是拆成用户模块、房源模块、订单模块、支付模块、评价模块。每个模块维护自己的Controller、Service、Mapper,模块之间通过明确的接口调用。这样做的直接好处是:订单模块要改状态机时,不需要去翻房源模块的代码;加一个新角色时,不用牵扯到所有接口。
2. 技术选型不是堆框架:SpringBoot生态里的取舍
2.1 后端核心框架组合与选择理由
这类项目最经典的组合就是SpringBoot + MyBatis-Plus + MySQL + Redis。简单说下每个选型背后的理由:
- SpringBoot:不用多讲,自动配置让项目能快速跑起来,内置Tomcat,写个Controller就能启动,生态成熟度在Java后端里没有对手。
- MyBatis-Plus:我特别推荐用它而不是原生MyBatis或JPA。MyBatis-Plus把单表CRUD直接封装好了,分页插件也很顺手,复杂查询还能自己写SQL。对客房租赁这种以单表操作为主、少量连表查询的项目,效率和可维护性都很平衡。JPA虽然也能做,但复杂的动态条件查询写起来不如MyBatis-Plus直观。
- MySQL:存储用户、房源、订单这些核心业务数据,关系型数据库在这个场景下没什么可替代性。
- Redis:不要一上来就把Redis神话了。这个项目里Redis主要干三件事:缓存热门房源的列表信息、存验证码、做并发扣库存的分布式锁。如果只是练手项目,不引入Redis也能跑,但引入之后你能真正理解缓存和分布式锁的作用,这部分经验价值很大。
版本选择上,现在新项目直接用SpringBoot 2.7.x或者3.x都可以。不过要注意,Spring Boot 3.x要求JDK17以上,不少同学习惯用JDK8,那还是老老实实用SpringBoot 2.7.x。技术永远为熟悉度服务,能用顺手的方式把项目做出来才是关键。
2.2 数据库设计:这几张表是核心骨架
数据库设计直接决定后面业务的顺畅程度。我见过太多项目把订单表塞满了所有字段,房源表里还存了日期库存,最后写SQL时痛苦不堪。推荐一张最小可运行的表清单,实际可根据需求扩展:
| 表名 | 核心字段 | 设计要点 |
|---|---|---|
| user | id、username、password、phone、role、status | 密码存BCrypt加密结果,role区分租客/房东/管理员 |
| house | id、owner_id、title、city、address、price、max_guests、status、create_time | status区分待审核/上架/下架,价格用DECIMAL |
| room_stock | id、house_id、stock_date、total_stock、locked_stock | 按日期记录每间房的库存,用locked_stock标记已被锁定的数量 |
| order_info | id、order_no、user_id、house_id、check_in_date、check_out_date、nights、amount、status | order_no加唯一索引,金额用DECIMAL,状态用int或varchar |
| evaluation | id、order_id、user_id、house_id、score、content、create_time | order_id加唯一约束,防止一条订单重复评价 |
几个常见坑提前说:
- order表名是MySQL的保留字,直接建表会报语法错误。要么用反引号包起来,要么像我这样叫order_info,省心。
- 金额一定用DECIMAL,不要用double。double转字符串出现1.199999999的问题你迟早会遇到。
- 日期库存表一定要做联合唯一索引,比如(house_id, stock_date),否则同一天能插入多条库存记录,搜索和扣减全乱套。
- 所有表都建议加create_time和update_time,后面做管理端列表排序、统计报表都用得到。
2.3 前后端交互方案与鉴权设计
前端方案上,如果目标是快速出成果,用服务端渲染的Thymeleaf就够;如果想写进简历,更建议前后端分离,前端用Vue3 + Element Plus,后端提供RESTful接口。
接口层面必须做统一返回体,我一般定义成Result类,包含code、message、data三个字段。这样前端处理逻辑统一,后端返回错误信息也规范,而不是哪里都随手返回一个Map。
鉴权方面,建议直接用JWT + HandlerInterceptor。流程是:用户登录成功后后端签发一个token,前端每次请求放在Authorization头里,后端写一个拦截器解析token并校验过期时间,再把当前用户信息放到ThreadLocal里供Controller使用。相比引入Spring Security那一大套配置,拦截器方式代码量小、逻辑透明,非常适合学习项目。如果你追求简历含金量,可以再学Spring Security,但起步阶段真没必要让配置复杂度拖慢进度。
3. 核心业务逻辑拆解:租房流程背后的数据流与状态机
3.1 房源发布与上下架流程怎么控制
房源不是简单“加一条记录”就完事的。从房东录入到用户看到,中间要经过审核,这是很多人忽略的业务细节。
房东提交房源后,房源的status应该先变成“待审核”,管理员在后台审核通过后才会变成“上架”状态。这样设计的好处是:管理端能控制平台上哪些房源可见,避免垃圾信息直接暴露给租客。即使是个人练手项目,也建议把这一步做进去,面试时能讲出“为什么要有审核环节”,这是加分项。
房源上下架还有一个容易踩的坑:下架房源不能直接物理删除。因为可能已经有租客在住,或者有订单正在进行。正确的做法是逻辑下架,也就是只把status改成“下架”,然后在下单逻辑里判断房源状态,只有“上架”状态的房源才能被搜索和下单。这样历史订单记录不受影响,管理端也能看到完整数据。
图片上传建议用本地文件存储就够了,把上传的图片保存到指定目录,数据库里存访问路径。要注意设置文件大小限制,SpringBoot默认1MB限制经常让人莫名其妙传不上去,我用一个配置类统一处理静态资源映射和上传大小。
3.2 搜索筛选与库存扣减的正确姿势
搜索功能是这个平台的门面。最基本的搜索条件包括城市、入住日期、退房日期、价格区间、可住人数。前端传这些参数,后端组装动态查询。
关键在于:怎么判断一间房在选定的日期区间内还有库存。方案有几种,最简单的入门写法是:先按城市等静态条件筛出房源列表,再排除掉在目标日期区间内库存不足的房源。因为库存是按日期粒度存的,只有给定区间内每一天的剩余库存都大于0,这间房才算可订。
这个逻辑可以用一条SQL写出来,也可以在内存中过滤。房源量不大时,先查出候选房源再逐套过滤性能完全够用。但要注意,不要在循环里挨个查询数据库,否则会出现严重的N+1查询问题。正确做法是查出候选房源id集合,用IN一次性把相关日期的库存数据查出来,再在内存里做统计。
库存扣减更是核心中的核心。先给结论:不要用“查询到库存大于0,然后在代码里减一,再更新”的方式,这个方式在高并发下一定会超卖。必须用带条件的原子更新:
UPDATE room_stock SET locked_stock = locked_stock + 1 WHERE house_id = #{houseId} AND stock_date BETWEEN #{startDate} AND #{endDate} AND total_stock - locked_stock > 0;执行这条SQL后,如果影响行数等于预定天数,说明锁定成功;否则说明某一天库存不足,整个订单失败,回滚事务。这种依赖数据库行锁的方式在中小型项目里非常实用,不建议一上来就上分布式锁,增加复杂度却不产生业务价值。
3.3 订单状态流转:从预订到入住再到退房
订单状态是类似平台最核心的难点。很多同学把状态设计成几个字段,业务逻辑里到处if else改状态,最后代码变成一团乱麻。我强烈建议把状态和动作抽离出来,做成一张状态流转表。
订单至少需要这些状态:
| 状态 | 含义 |
|---|---|
| PENDING_PAYMENT | 待支付,创建订单后的初始状态 |
| PAID | 已支付,表示订单生效 |
| CHECKED_IN | 已入住,到店确认后进入 |
| CHECKED_OUT | 已退房,订单正常完结 |
| CANCELED | 已取消,超时或用户主动取消 |
| REFUNDING | 退款中,支付后取消需要退款 |
而支撑这些状态迁移的动作主要有:创建订单、支付成功、取消订单、确认入住、确认退房、超时关闭。每个动作只能从特定状态迁移到特定状态,比如“支付成功”只能作用于PENDING_PAYMENT状态,如果一笔已经CHECKED_OUT的订单还能被“支付成功”,那业务就彻底失控了。
实现上建议做一个状态机处理器,内部维护一张“当前状态 + 动作 → 新状态”的映射表,每次状态变更都走这个处理器。这样所有状态流转规则集中管理,新增状态时只改映射表,不需要去翻遍布全项目的if else。这个设计本身就是面试时值得讲清楚的点。
4. 实操环节:核心模块的编码实现思路
4.1 房源模块的Controller与Service层设计
房源模块向前端提供搜索、详情、发布、上下架几个接口。我习惯把Controller写得很薄,所有业务逻辑下沉到Service层。比如搜索接口的Controller层只做参数接收和结果返回:
@RestController @RequestMapping("/api/houses") public class HouseController { @GetMapping("/search") public Result search(@RequestBody HouseSearchDTO dto) { PageResult<HouseVO> page = houseService.search(dto); return Result.ok(page); } }真正的业务逻辑放进Service层:动态拼接查询条件、调用库存表筛选可预订房源、按价格排序、分页。MyBatis-Plus的LambdaQueryWrapper在这里很好用,动态条件只要StringUtils.hasText判断一下就行:
LambdaQueryWrapper<House> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(dto.getCity()), House::getCity, dto.getCity()); wrapper.eq(dto.getMinPrice() != null, House::getPrice, dto.getMinPrice()); wrapper.eq(dto.getMaxGuests() != null, House::getMaxGuests, dto.getMaxGuests());但要注意,MyBatis-Plus自动生成的Wrapper只擅长单表简单查询。涉及库存过滤这种跨表逻辑,直接用自定义SQL更清晰,我一般会在Mapper接口里用@Select注解写原生SQL,可读性反而更高。
4.2 订单状态机如何落实到代码
状态机落到代码层面,我以前写过一版比较清爽的实现。定义订单当前状态和待执行动作两个枚举,然后在状态机处理类里维护一个Map:
Map<OrderStatus, Map<OrderAction, OrderStatus>> STATE_MACHINE = new HashMap<>(); static { Map<OrderAction, OrderStatus> pendingActions = new HashMap<>(); pendingActions.put(OrderAction.PAY, OrderStatus.PAID); pendingActions.put(OrderAction.CANCEL, OrderStatus.CANCELED); STATE_MACHINE.put(OrderStatus.PENDING_PAYMENT, pendingActions); Map<OrderAction, OrderStatus> paidActions = new HashMap<>(); paidActions.put(OrderAction.CHECK_IN, OrderStatus.CHECKED_IN); paidActions.put(OrderAction.APPLY_REFUND, OrderStatus.REFUNDING); STATE_MACHINE.put(OrderStatus.PAID, paidActions); // 其他状态迁移同理 }执行动作时,先取出当前状态对应的动作映射,如果动作不存在,直接抛异常提示非法操作;如果存在,就把订单状态更新为目标状态。更新时SQL也要带上旧状态条件,防止并发状态下两个请求同时把同一订单从待支付改成不同状态。这条SQL虽然简单,但它是防并发状态错乱的最后一道保险:
UPDATE order_info SET status = #{newStatus}, update_time = NOW() WHERE id = #{id} AND status = #{oldStatus}4.3 定时任务处理过期订单与数据一致性
订单创建后,如果用户一直不支付,库存会被一直锁着,这对平台来说就是资源浪费。解决办法是用定时任务扫描超过30分钟未支付的订单,将其置为取消状态,并释放对应日期的锁定库存。
SpringBoot里用@Scheduled就能搞定,配置cron表达式,比如每分钟执行一次:
@Scheduled(cron = "0 * * * * ?") public void closeExpiredOrders() { List<OrderInfo> expiredOrders = orderMapper.findExpiredOrders(30); for (OrderInfo order : expiredOrders) { orderService.cancelOrder(order.getOrderNo(), SystemUser.SYSTEM); } }这里有个容易忽略的问题:定时任务一定要做幂等控制。如果同一笔过期订单被扫描到两次,cancelOrder里第二次执行时状态已经不是待支付,状态机那一步就会拦截,理论上不会出大问题。但更稳妥的做法是加一个任务执行记录,或者用分布式锁(多实例部署时),避免重复处理。
还有一个一致性问题:先更新订单状态还是先释放库存?我的建议是先更新订单状态,成功后再释放库存,两步放在同一个事务里。因为释放库存失败可以重试,但订单状态如果已经被更新为取消,重复取消也没有副作用。反过来就麻烦,库存释放了但订单还是待支付,用户一支付发现没有库存,体验极差。
5. 真实项目中的坑与排查实录
5.1 并发超卖问题:库存扣减不是“查了再减”
前面讲库存时提过超卖,这里展开说。最常见也最致命的错误写法是:
// 错误示例:先查库存再判断再更新 int stock = roomStockMapper.selectStock(houseId, date); if (stock > 0) { roomStockMapper.decreaseStock(houseId, date); }两个请求同时查到库存为1,都判断大于0,然后都执行扣减,最后库存变成-1,超卖发生。这个问题在单机环境下用数据库更新锁可以解决,核心就是必须把“判断”和“更新”合成一条原子SQL。上面给过的total_stock - locked_stock > 0方案就是标准解法。
如果后续要做高并发版本的演示,可以在Redis里提前预热库存,扣减时用Lua脚本保证原子性。但说实话,没有真实流量压测的话,Redis扣库存更多是面试谈资。先把数据库方案用明白,比花里胡哨更重要。
5.2 缓存与数据库一致性怎么处理
房源列表数据变化频率不高,非常适合加Redis缓存。比如首页热门房源,可以直接把查询结果序列化成JSON存到Redis,key设置一个过期时间,比如10分钟。
但缓存最怕数据不一致。房源被下架了,缓存里还是显示上架,用户点进去才发现不能预订,体验很糟。我采用的策略很简单粗暴:先更新数据库,再删除缓存。等下一次查询时缓存不存在,重新从数据库加载数据再放入Redis。这个策略在绝大多数场景下都没问题,比更新缓存要可靠得多。
需要注意:删除缓存失败会导致旧数据一直留在Redis里,所以生产环境会加重试机制。个人项目可以不用做这么重,但至少要在代码里打个日志,方便排查。
5.3 那些不起眼但能卡你半天的小坑清单
这类项目里的坑不少,我挑几个高频的整理在一块,遇到直接对症下药:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 前后端日期传参报错或差8小时 | MySQL时区和Jackson序列化没配好 | 连接串加serverTimezone=Asia/Shanghai,实体字段用LocalDate/LocalDateTime |
| 金额显示1.199999999 | 用了double或float存金额 | 数据库和实体类全部改成BigDecimal |
| 分页查询total总为0 | 没配置MyBatis-Plus分页插件 | 启动类加MybatisPlusInterceptor并注册PaginationInnerInterceptor |
| 图片上传报MaxUploadSizeExceededException | SpringBoot默认上传限制1MB | 配置spring.servlet.multipart.max-file-size和max-request-size |
| 两个请求同时取消同一笔订单 | 没控制并发状态更新 | 更新语句带上旧状态条件,影响行数为0则说明更新失败 |
| LocalDateTime返回前端变成数组 | Jackson没注册JavaTimeModule | 引入jackson-datatype-jsr310并配置时区 |
这些坑看起来小,但排起来极费时间。尤其是时区问题,八小时偏差很容易让人怀疑人生。另外提醒一句,开发时统一用LocalDate表示日期,不要用Date到处传,日期格式化的问题会少一半以上。
最后再分享一个个人习惯:这类业务系统,我拿到需求一定先把状态流转图画清楚,再写一行代码。房源状态是一套,订单状态是一套,甚至支付和退款状态也可以单独抽一套。状态之间怎么流转、哪些动作非法、每个动作影响哪些数据,这些理清了,写代码就是填格子的事情。
踩过几次坑之后我的体会是,客房租赁平台的难点根本不在于框架用得多花哨,而在于能不能把业务流程老老实实做成闭环。一个能走通“发布房源→搜索→下单→支付→入住→退房→评价”全流程的系统,哪怕界面朴素一点,也比那种功能堆了一大堆却互相矛盾的项目有价值得多。如果你也在搞类似的系统,先别急着加功能,把订单状态机扣清楚,把库存扣减写正确,把缓存一致性问题想明白,你的项目就已经超过大多数同类作品了。