☰
Spring Boot青年公寓平台开发实战:状态机、事务与权限控制
2026/10/24 7:46:11 网站建设 项目流程

这几个月一直在折腾一个 Spring Boot 的青年公寓服务平台项目,昨天总算把所有核心流程跑通了。项目代号 11709,定位是给刚毕业的年轻人提供找房、看房、签合同、缴费、报修的一站式服务,公寓运营方则通过后台管理房源、审批预约、生成合同、查看账单。整个开发周期从需求梳理到核心接口跑通,前前后后花了一个多月,踩了不少坑,也趟出了一些值得记录的经验。

如果你是正在做毕业设计、想熟悉 Spring Boot 完整项目流程的开发者,或者准备自己搭一套租赁类服务平台的个人开发者,这篇内容应该能帮你少走不少弯路。做这类平台,最大的误区是一上来就写代码。我先把业务边界和状态流转理清楚,后面写起来才顺。所以这篇会从业务分析讲起,再落到技术选型、数据库设计、核心接口实现,最后把开发中遇到的实际问题和优化思路原原本本摆出来。

1. 青年公寓平台要解决的痛点:从看房到退租的全链路管理

青年公寓和传统租房最大的区别在于:房源相对集中、租客画像清晰、合同和账单的周期性强。这意味着平台不能只是一个信息展示页,而是要把“找房—看房—签约—缴费—居住服务—退租”这条链路上的每个环节都管理起来。业务侧理不清,代码写得再好也是空中楼阁。

1.1 两类角色的真实需求拆解

平台的用户分成租客和运营方两类,他们看到的数据和操作权限天然不同。整理需求时我把两类角色分列两张表,每条需求都标注了涉及的数据表,这样后面设计接口就有据可循。

租客侧的核心诉求是三个:找房快、流程透明、报修有反馈。具体到系统功能,包括浏览公寓和房间列表、按价格和朝向筛选、提交预约看房申请、查看自己的预约状态、签署电子合同、查看每月账单、提交报修工单并跟踪处理进度。运营方侧,管家负责房源的上下架与维护、审批预约、创建合同、生成账单、处理报修工单;管理员则负责用户管理、角色分配和数据统计。

这两类角色的需求差异决定了系统在架构上必须做权限隔离,不能是单一角色一把梭。最简单直观的做法是用一个 user 表加 role 字段区分,再配合接口级别的权限控制。这个设计直接决定了后续所有查询都要带上“当前登录用户是谁”这个维度,后面第 4 节会详细展开。

1.2 五条业务主线:房源、预约、合同、账单、报修

抽象下来,核心业务是五条主线,它们之间是有先后依赖的:预约通过才能生成合同,合同生效后房间状态改为已租,账单跟着合同周期走,报修是合同生效后才可能产生的服务。这个依赖关系直接映射到数据库外键和代码事务上。

  • 房源主线:公寓-房间两级结构,房间有独立状态,运营方可上下架。
  • 预约主线:租客选定房间和看房时间段,运营方确认或驳回。
  • 合同主线:预约确认后生成电子合同,包含起止日期、押金、租金、缴费周期。
  • 账单主线:按合同周期生成账单,租客缴费,系统记录缴费状态。
  • 报修主线:租客上报问题,运营方指派维修人员,闭环处理。

为什么做公寓-房间两级结构,而不是一张房间表直接搞定?实操中你会发现,同一个公寓的多间房共享公共区域和地址信息,图片、地址、简介只需要维护在公寓层;每间房则只需要维护楼层、面积、朝向、租金这些独立属性。列表页先展示公寓,点进去再展示具体房间,这种两级结构既符合用户找房的心智模型,也避免了一张大表里塞满冗余字段。

2. 技术选型与项目骨架搭建:为什么用 Spring Boot 这套组合

技术选型这件事,适合的才是最好的。这套平台的并发量不会很高,单体架构完全顶得住,所以我从一开始就排除了微服务那套东西——分布式事务、服务注册、链路追踪带来的复杂度,对这个规模的项目来说纯属自找麻烦。

2.1 后端框架:Spring Boot 2.7.x 的取舍

选 Spring Boot 的理由大家都清楚:生态成熟、配置简化、开箱即用。版本我用的 2.7.x,配合 JDK 11。为什么不追新用 3.x?因为项目里要用的不少第三方兼容包在 2.7 上最稳定,而且对于业务系统来说,稳定压倒一切。JDK 8 其实也完全够用,只是本机环境是 JDK 11,就直接用了。

分层上,controller 只做参数接收和结果封装,service 写业务逻辑,mapper 只管 SQL。这几乎是所有 Spring Boot 项目的固定套路,简单、好排查、团队成员接手也快。统一返回结构用 Result<T> 包装,code、message、data 三件套,前端只用判断 code 就能知道接口是否成功。

2.2 持久层:MyBatis-Plus 与 MySQL 8.0 的搭配

持久层选 MyBatis-Plus 而不是原生 MyBatis,核心原因是单表 CRUD 的效率差异。MyBatis-Plus 的 BaseMapper 和 IService 直接内置了增删改查、分页、条件构造器,单表的 selectById、save、updateById 根本不用写 SQL,开箱即用。遇到复杂统计或联表查询时,再用 @Select 注解写原生 SQL,两边互补。

数据库用的 MySQL 8.0,建表时统一用 utf8mb4 字符集。这里有个细节:utf8mb4 和 utf8 不只是差的字节数问题,utf8mb4 支持完整的 Unicode 字符集,用户昵称这类字段如果遇到特殊字符,用 utf8 就可能导致入库报错。金额字段统一用 decimal(10,2),时间字段用 datetime,这是这类业务系统的基本规范。

2.3 Redis、Spring Security 与 JWT 的协作

认证方案上,我用了 Spring Security 加 JWT 的无状态登录方式。为什么不用传统 Session?因为这个平台是前后端分离的,前端可能是网页、小程序甚至 App,JWT 天然无状态,移动端可以直接复用同一套认证逻辑。登录成功后签发一个带 userId 和 role 的 token,前端每次请求在 Authorization 头带上,后端过滤器校验 token 有效性,再放行到对应接口。

Redis 在这个项目里承担了三件事:缓存公寓基础信息这类低频变化数据、处理合同编号的每日自增序号、以及辅助预约并发控制。初期我把 Redis 只当缓存用,后来发现它在做原子自增这类场景里才是真正不可替代的。缓存的核心原则是只缓存变化频率低的数据,这个后面讲性能优化时还会提到。

2.4 项目目录结构与基础包划分

com.example.apartment ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── dto // 接收参数 ├── vo // 返回结果 ├── config // 配置类 └── common // 统一返回、异常、工具类

实体和 VO 分开是我坚持的原则。entity 严格对应数据库字段,VO 是根据接口需要拼装出来的返回对象。很多新手图省事直接用实体返给前端,结果一旦数据库表加了字段,对外接口的返回结构也跟着变,非常不可控。异常处理用全局 @RestControllerAdvice,业务异常抛 BizException 并统一转换成约定的 code 和 message,避免堆栈信息直接暴露给前端。

3. 核心接口实现:房源状态管理、预约防冲突与合同事务

业务主线理清后,最核心的三块硬骨头是房源状态管理、预约看房的并发防护、合同创建的事务一致性。这三个问题在同类系统里几乎必然遇到,值得单独拿出来细说。

3.1 房源状态机与乐观锁

房间状态用 status 字段维护,取值有三种:AVAILABLE(空闲)、RENTED(已租)、MAINTENANCE(维护中)。状态转换有明确的规则:

  • AVAILABLE → RENTED:合同生效时触发
  • AVAILABLE → MAINTENANCE:运营方手动设置
  • RENTED → AVAILABLE:合同到期或租客退租
  • MAINTENANCE → AVAILABLE:维护完成

为什么状态转换要这么严格?因为状态直接决定了用户端能不能看到这间房、能不能预约。如果运营方把一间已租的房间误设为维护中,租客的合同还挂在上面,显示的却是维护中,数据冲突一旦发生,定位成本非常高。

状态更新的并发问题必须防。多个运营人员可能同时操作同一间房,比如 A 把房间改为已租,B 同时设为维护中,后提交的更新会把前一个操作直接覆盖。我采用的方案是 MyBatis-Plus 的乐观锁:实体类加 @Version 字段,更新时自动带上 where version = ?,版本不对就更新失败,抛出异常后提示“房源状态已变更,请刷新后重试”。这是成本最低且可靠性最高的方案,比起手动加 SELECT FOR UPDATE 锁表,乐观锁对并发读场景更友好。

3.2 预约看房的时间段防冲突

预约的核心是时间段冲突检测。一个房间的某个看房时间段,只能被一个用户成功预约。预约表里直接存 start_time 和 end_time 两个字段,代表看房时间段。冲突检测的 SQL 是:

SELECT COUNT(*) FROM appointment WHERE room_id = #{roomId} AND status IN ('PENDING', 'CONFIRMED') AND start_time < #{endTime} AND end_time > #{startTime}

这套条件跟“区间重叠”的数学定义一致:两个区间有交集,当且仅当一个区间的开始早于另一个区间的结束,同时这个区间的结束晚于另一个区间的开始。已有预约 [10:00, 11:00),新预约 [10:30, 11:30),条件成立,检测出重叠;新预约 [11:00, 12:00),则 end_time > 11:00 不成立,不重叠,符合业务语义。

但单纯靠 SQL 条件还不够,因为两个用户同时提交时,可能都查出来是 0,然后都去插入,造成超卖。我的处理是在一个事务里,先对房间记录加行锁,再执行冲突检测,最后插入预约:

@Transactional public void createAppointment(CreateAppointmentDTO dto) { // 1. 锁住房间记录,串行化同一房间的预约操作 Room room = roomMapper.selectByIdForUpdate(dto.getRoomId()); if (room == null) { throw new BizException("房间不存在"); } // 2. 冲突检测 int count = appointmentMapper.countConflict(dto.getRoomId(), dto.getStartTime(), dto.getEndTime()); if (count > 0) { throw new BizException("该时间段已被预约"); } // 3. 插入预约 appointmentMapper.insert(buildAppointment(dto)); }

selectByIdForUpdate 对应的 SQL 是SELECT * FROM room WHERE id = #{id} FOR UPDATE。InnoDB 行锁保证同一时间只有一个事务在处理同一房间的预约,从根上避免超卖。不同房间之间互不影响,并发度对这个体量的平台完全够用。比起引入 Redis 分布式锁,这种数据库层面串行化的方案实现简单、运维成本低,是这个场景下的最优解。

3.3 合同创建的事务一致性

预约确认后,运营方在后台创建合同。合同表的核心字段包括:合同编号、起止日期、押金金额、月租金、缴费周期(月付/季付/年付)。创建合同的接口里有两件事必须在同一个事务里完成:插入合同记录,同时把房间状态从 AVAILABLE 改为 RENTED。用 @Transactional 保证原子性:

@Transactional public ContractVO createContract(CreateContractDTO dto) { // 1. 校验房间状态 Room room = roomMapper.selectById(dto.getRoomId()); if (!"AVAILABLE".equals(room.getStatus())) { throw new BizException("房间当前不可签约"); } // 2. 插入合同 Contract contract = buildContract(dto); contractMapper.insert(contract); // 3. 更新房间状态 room.setStatus("RENTED"); roomMapper.updateById(room); return convert(contract); }

合同插入失败,房间状态回滚;房间状态更新失败,合同记录也不存在。事务注解容易加,但要注意事务方法必须短小精悍,内部别做远程调用或耗时操作,否则会长时间持有数据库连接。另外,合同编号不能随便用 UUID,太长且没法一眼看出是哪天签的,我用的生成规则是"HT" + yyyyMMdd + 当日序号,比如 HT202506010001,后面第 5 节会讲基于 Redis 原子自增的具体实现。

金额字段的处理是这里最容易踩坑的地方。凡是涉及钱的字段,一律用 BigDecimal,绝不能用 double。double 在加减运算中会有精度丢失,0.1 加 0.2 的结果是 0.30000000000000004,这种问题到账单统计时会直接变成财务事故。Java 侧接收页面传来的金额字符串后直接转 BigDecimal,数据库字段用 decimal(10,2),两边一致才安全。

4. 权限认证与数据隔离:三种角色如何安全地各看各的数据

认证和授权是管理类系统的命门。这个平台有三种角色:租客(TENANT)、运营方(MANAGER)、管理员(ADMIN)。租客只能操作自己的数据;运营方能管理房间、预约、合同、账单;管理员额外拥有用户管理和角色分配权限。如果权限控制不到位,一个租客改个参数就能看到别人的账单,系统就废了。

4.1 JWT 登录认证的完整链路

登录流程是标准的 Spring Security 加 JWT 组合:

  1. 前端提交 username 和 password。
  2. 后端通过 UserDetailsService 加载用户信息,校验密码。
  3. 校验通过后,用配置好的密钥生成 JWT,token 里带上 userId 和 role。
  4. 前端后续请求在 Authorization 头附上 Bearer token。
  5. 后端过滤器解析 token,把用户信息放进 SecurityContext。
  6. 带状态请求的接口验证通过后放行。

这个链路里容易出问题的是 token 过期时间。当时设的 24 小时,结果前端反馈第二天一早所有请求全部 401。后来改成 7 天,并在登录接口做了 token 续期的处理。移动端场景下用户可能一周都不重新登录,过期太短体验会非常糟糕。

4.2 接口权限控制的实现方式

权限控制用了 Spring Security 的 @PreAuthorize 注解,在所有需要鉴权的接口方法上标注角色要求:

@PreAuthorize("hasRole('ADMIN')") @GetMapping("/admin/users") public Result<List<UserVO>> userList() { ... }

运营方角色无法访问管理员接口,框架层直接拦截。控制器层面的注解式鉴权的好处是直观看得见,不用去翻 XML 过滤器链。

租客侧的数据隔离是另一个重要问题。租客查询自己的账单、合同、预约时,接口需要知道当前登录用户是谁,这个用户信息必须从 JWT 解析出来,而不是相信前端传的 userId。我实际测试时专门试过伪造 userId 去查别人的数据,发现后端是从 token 里取的当前用户 ID,根本没有用页面传的参数,越权请求被挡了下来。这个看起来基础的细节,恰恰是很多权限漏洞的根源。

4.3 账单缴费与报修工单的流转逻辑

账单模块的逻辑相对简单但涉及状态多。每个合同周期生成一笔账单,状态有 UNPAID(待支付)、PAID(已支付)、OVERDUE(已逾期)。账单可以由运营方手动生成,也可以用定时任务在每月初自动生成,建议先做手动生成,跑通全流程后再上定时任务,避免一开始就被自动任务的边界条件搞崩溃。

报修工单的状态一般有四步:SUBMITTED(已提交)、PROCESSING(处理中)、FINISHED(已完成)、CANCELED(已取消)。租客提交工单时上传图片和问题描述,运营方接单后设置处理进度,完成后租客可以在前端确认。这里要提醒的是,工单状态变更的操作权限必须控制好,租客能提交和取消,但不能自己把状态改成处理中,否则状态机就乱套了。

5. 实测遇到的坑与优化记录:日期、并发、缓存和排查思路

最后这部分是最想分享的,全是实际踩过的坑和对应的排查思路。这里的很多问题不是 Spring Boot 本身的坑,而是业务设计不到位带来的连锁反应,排错过程比结论更有参考价值。

5.1 日期时间问题的排查:差 8 小时?

第一次联调时前端反馈,预约时间在页面上显示的总是比预期晚 8 小时。我下意识以为是数据库时区问题,查了 MySQL 的 time_zone,发现是系统默认时区,又查 JDBC 连接串,加了 serverTimezone=Asia/Shanghai,问题依然存在。

最后定位到代码里用了 Date 类型,序列化时用了 SimpleDateFormat。Date 本身没有什么错,错在 SimpleDateFormat 默认用的是 JVM 本地时区,前端拿到的是东八区字符串,但页面表单回显时又按无时区偏移解析了一遍,一来一回就差了 8 小时。排查顺序暴露了思维盲区:先查数据库没问题,再查连接串也没问题,唯独没先查应用层的日期序列化配置。后来统一改成 JDK 8 的 LocalDateTime,配合全局 Jackson 序列化配置,前端约定传 ISO 格式字符串,问题彻底消失。肉眼可见的时间问题,八成出在序列化而不是数据库,这个排查顺序不能反了。

5.2 预约时间冲突的两个边界问题与并发实测

第一版冲突 SQL 里我用的是小于等于和大于等于:

SELECT COUNT(*) FROM appointment WHERE room_id = #{roomId} AND start_time <= #{endTime} AND end_time >= #{startTime}

结果出现误杀:已有预约 [10:00, 11:00),新预约 [11:00, 12:00),正常业务语义是相邻时段不冲突,但这个条件在边界重合时判定为冲突,前端用户会发现所有挨着的时段都约不了。改成严格小于和严格大于后,只有真正的区间重叠才会命中,相邻时段恢复正常。

另一个坑是状态过滤。预约状态有 PENDING、CONFIRMED、CANCELED、FINISHED,一开始没过滤状态,已取消的预约仍然占着时间段。后来在条件里加上 status IN ('PENDING', 'CONFIRMED'),取消的预约才会释放时间段。这个问题的根因是业务语义理解不到位,SQL 看起来没语法错误,但业务结果完全不对。

并发场景用 FOR UPDATE 锁房间记录后,做了一轮小测试:同一房间同一时间段,100 个请求同时预约,最终只有 1 条成功插入,其他全部返回“该时间段已被预约”。FOR UPDATE 锁的粒度是行级,只影响同一房间的预约操作,不同房间之间并发完全不受影响,对这个体量的平台性价比极高。

5.3 列表查询的性能优化:缓存不是万能药

房源列表页在数据量过千后出现明显卡顿。最初的优化思路是先上 Redis 缓存整个列表,结果发现完全行不通——租金、状态、上下架信息随时在变,缓存一致性处理起来极其痛苦。后来退了一步,把缓存范围缩小到公寓基础信息这种变化频率极低的静态数据,房间列表保持实时查库,同时给房间表加了复合索引 (apartment_id, status),查询性能从数千毫秒降到了几十毫秒。

这个决策带来的启发是:缓存是给变化频率低的数据准备的,频繁变化的数据强上缓存,解决一个性能问题,会制造十个一致性问题。与其折腾缓存,不如先把数据库索引和 SQL 执行计划优化到位。对列表页这种带筛选条件的查询,最有价值的往往就是一条组合恰当的索引。

5.4 合同编号生成的并发问题与 Redis 原子自增

合同编号一开始用 UUID,不会重复,但 32 位字符串拿到手机上显示就是灾难,运营方根本没法口述或记忆。后来改成“HT”加时间戳,并发一上来就偶尔出现重复。最终方案是用 Redis 的 INCR 命令生成当日序号:

合同编号 = "HT" + yyyyMMdd + String.format("%04d", redis.incr(key))

Redis 的 INCR 是原子操作,天然防止并发下序号重复;每天的序号 key 用日期做后缀,晚上设置过期时间,避免 key 累积。合同号格式如 HT202506010001,运营方一看就知道是哪天签的,体验比 UUID 好太多。服务启动时如果 Redis 不可用,序号回退到数据库已有合同编号的最大序号加一,保证系统基本可用,这个小兜底在运维时省了不少事。

5.5 联调效率的关键:自动生成的接口文档

最后聊一个不是代码却极大影响开发效率的点:接口文档。项目里集成的是 Knife4j,界面比原生 Swagger 清爽很多。每次定义好接口后,前端直接刷新页面就能看到最新的参数说明和响应示例,配合 @ApiOperation 注解写清楚接口用途,基本没有因为接口文档对不上而扯皮过。

很多团队在联调阶段最烦的就是“接口和文档不一致”,根源其实是没让文档自动生成。手动写的文档跟不上代码变化,自动生成的文档永远和代码同步,这个投资非常值。


最后说点个人体会。做这类管理系统,最大的敌人往往不是框架本身,而是对业务边界的模糊理解。我把大量时间花在梳理状态机和权限边界上,实际编码阶段反而推进得很快。做 Spring Boot 平台项目,先把业务拆清楚,再把数据访问层和权限控制做扎实,最后优化性能和联调也就是水到渠成的事。如果你也在做类似的公寓或租赁系统,强烈建议先花两三天把状态转换和角色权限画明白,再动手写第一行代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询