ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot三端租房系统:从状态机到Token鉴权的完整落地

SpringBoot三端租房系统:从状态机到Token鉴权的完整落地 简介基于SpringBoot框架的智能租房全流程管理系统源码包面向需要学习企业级前后端分离项目的开发者或希望快速搭建房屋租赁平台的技术团队。系统采用前后端分离架构覆盖管理端、屋主端、租客端三端协同包含房源上下架、订单处理、预约看房、评价反馈、通知公告等核心模块并支持用户认证授权、数据加密传输等多重安全机制可完整支撑房屋租赁业务闭环。资源共816个文件压缩包大小26.43MB。其中java文件承载后端业务逻辑vue与js文件负责前端页面与交互html/css/svg构建界面展示xml/yml等存放项目配置bat脚本及备份文件则便于部署运行与数据维护整体目录结构清晰适合按模块逐步研读。已有45人学习适合具备一定Java与Vue基础的中高级开发者。下载后可获得完整工程目录、三端业务代码、部署配置与运行脚本能够帮助理解前后端分离项目的模块划分、接口协作与多角色权限管理。1. 三端租房系统真正难的不是三端而是三种视角共用一套业务做毕设或者接手一个租房平台项目时看到“管理端、屋主端、租客端”三端协同第一反应通常是先建三套用户表再搭三套接口最后联调时发现改一处要动三处。实际这类项目的业务总量并不大房源、订单、预约就三张核心表三端的差别只在于谁有权限看什么、谁能把状态往前推一步。所以先定数据模型和状态机再谈前后端分离和接口设计才是落地顺序。这篇从SpringBoot后端建模、状态流转、Token鉴权到部署验证一条线写清楚适合正在做房屋租赁毕设或想接手前后端分离租房项目的开发者照着复现。2. 三端协同的根基权限模型与公共数据字典2.1 单体多模块是租房平台最稳的形态三端协同并不意味着每端拆一个后端服务。租房系统典型的并发量比电商低一个数量级拆微服务只是增加联调成本和部署负担。最稳的做法是在一个SpringBoot工程里按业务域拆包公用一套MySQL和公共模块三端差异通过角色和接口路径区分就够了。常见做法是① 用户表用 role 字段区分 ADMIN / OWNER / RENTER ② 接口按 /admin/**、/owner/**、/renter/** 划分前缀 ③ 登录后前端按角色渲染不同菜单后端按路径拦截鉴权 ④ 业务包按 house、order、appointment 这类目录拆。这种做法的好处是一次登录态三端共用Token里带上userId和role拦截器只需要做两件事确认Token有效、确认路径前缀和角色匹配。如果拆成三个服务改一个表结构就要同步更新三个工程反而不符合三端协同的维护预期。若依这类前后端分离脚手架也是类似思路——用户、角色、菜单做RBAC但在租房场景里RBAC只能解决“能不能进这个页面”解决不了“这个房源能不能被当前屋主改价”后者要靠数据归属判断。所以在设计权限时除了角色还要把数据行归属ownerId一起带上接口查询强制加owner_id条件防止越权改别人的房源。2.2 先把三个状态机钉死房源、订单、预约这类项目逻辑最容易烂的地方是状态流转。换一个开发拍脑袋再加一个状态前后端对不上就变成两个项目了。先把状态机定成文档写在表注释里业务对象状态值状态含义谁可以触发房源 house0待审核屋主提交管理员审核房源 house1已上架管理员审核通过屋主可见可改价房源 house2已下架管理员或屋主主动下架房源 house3已租出订单生效后对租客端隐藏订单 rent_order0待支付租客下单订单 rent_order1已支付待起租租客支付屋主可见订单 rent_order2履行中管理员按合同开工或系统按起租日流转订单 rent_order3已退租屋主或管理员关闭订单 rent_order4已取消租客取消、超时支付关闭预约 appointment0待确认租客申请预约 appointment1已确认屋主确认预约 appointment2已完成看房结束后租客确认完成预约 appointment3已取消租客取消、屋主拒绝同时要规定非法流转比如“已上架”不能直接“已租出”必须先有订单生效“已退租”的房源不回到“已上架”而是先回“待审核”避免有住户的房源被重复挂出。状态机定完接口参数里的status就不再是随便填的int而是由Service层做状态机校验不符合流转规则就抛业务异常。这样三端前端可以根据同一个状态码画按钮显隐后端不需要为每端各写一套逻辑。2.3 三张核心表的建表骨架下面是去掉次要字段后的最小表结构覆盖房源信息、订单处理、预约看房三个主流程。字段命名和管理端、屋主端、租客端三端的查询条件一一对应。CREATE TABLE house ( id BIGINT NOT NULL AUTO_INCREMENT, owner_id BIGINT NOT NULL COMMENT 屋主用户id, title VARCHAR(120) NOT NULL, cover_img VARCHAR(255) NULL, address VARCHAR(255) NULL, area DECIMAL(10,2) DEFAULT 0 COMMENT 面积 平方米, rent DECIMAL(10,2) NOT NULL COMMENT 月租金, audit_status TINYINT DEFAULT 0 COMMENT 0待审核 1已上架 2已下架 3已租出, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_owner (owner_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT房源表; CREATE TABLE rent_order ( id BIGINT NOT NULL AUTO_INCREMENT, house_id BIGINT NOT NULL, renter_id BIGINT NOT NULL, owner_id BIGINT NOT NULL, start_date DATE NOT NULL COMMENT 起租日期, end_date DATE NOT NULL COMMENT 结束日期, month_rent DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2履行中 3已退租 4已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_house (house_id), KEY idx_renter (renter_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT租约订单表; CREATE TABLE appointment ( id BIGINT NOT NULL AUTO_INCREMENT, house_id BIGINT NOT NULL, renter_id BIGINT NOT NULL, owner_id BIGINT NOT NULL, visit_time DATETIME NOT NULL COMMENT 预约看房时间, status TINYINT DEFAULT 0 COMMENT 0待确认 1已确认 2已完成 3已取消, remark VARCHAR(500) NULL, PRIMARY KEY (id), KEY idx_house (house_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约看房表;字段说明里值得注意的几点订单表同时存 renter_id 和 owner_id是为了管理端后台列表不需要 JOIN 两遍用户表直接按角色过滤查询速度更快代价是数据冗余租房场景写多读少这个冗余是可接受的。房源表地址不加唯一约束。现实中同小区同户型会有多套房源唯一约束反而误伤正常数据。三张表都有status但语义完全不同联表时不要用status做业务判断用订单状态反推房源状态否则管理端改一个字段会牵连其他端。visit_time 在预约表按 DATETIME 存前后端统一用时间戳字符串传递避免时区换算导致预约时间对不上。3. SpringBoot后端核心模块预约、订单、房源审核的落地3.1 把Controller拆成三层而不是三端很多做前后端分离的人会把“三端”理解成三套Controller。实际接口是按业务资源拆角色验证放在拦截器里Controller不需要知道调用方是谁——UserContext里有当前登录人的id和role。这样写的好处屋主管理房源、管理员审核房源前端调用的是不同路径后端可以复用同一个HouseService状态机的变更逻辑集中在Service层Controller只做参数校验和响应封装三端前端看到的字段差异用VO解决后端不必为每端写一套CRUD。3.2 用状态机校验的预约闭环代码预约看房是三类角色交互最多的一条链路租客预约、屋主确认、租客完成或取消。下面是简化后的实现重点看状态校验怎么写RestController RequestMapping(/api/appointment) public class AppointmentController { private final AppointmentService appointmentService; public AppointmentController(AppointmentService appointmentService) { this.appointmentService appointmentService; } PostMapping(/create) public ResultLong create(RequestBody AppointmentCreateDTO dto) { Long renterId UserContext.getUserId(); return Result.ok(appointmentService.create(renterId, dto)); } PostMapping(/{id}/confirm) public ResultVoid confirm(PathVariable Long id) { appointmentService.transit(id, AppointmentStatus.CONFIRMED); return Result.ok(); } PostMapping(/{id}/cancel) public ResultVoid cancel(PathVariable Long id) { appointmentService.transit(id, AppointmentStatus.CANCELED); return Result.ok(); } }Service public class AppointmentServiceImpl implements AppointmentService { private final AppointmentMapper appointmentMapper; private final HouseMapper houseMapper; public AppointmentServiceImpl(AppointmentMapper appointmentMapper, HouseMapper houseMapper) { this.appointmentMapper appointmentMapper; this.houseMapper houseMapper; } Override Transactional public Long create(Long renterId, AppointmentCreateDTO dto) { House house houseMapper.selectById(dto.getHouseId()); if (house null || house.getAuditStatus() ! HouseStatus.ON_SHELF) { throw new BizException(该房源当前不可预约); } Appointment appointment new Appointment(); appointment.setHouseId(dto.getHouseId()); appointment.setRenterId(renterId); appointment.setOwnerId(house.getOwnerId()); appointment.setVisitTime(dto.getVisitTime()); appointment.setStatus(AppointmentStatus.PENDING); appointmentMapper.insert(appointment); return appointment.getId(); } Override Transactional public void transit(Long id, Integer targetStatus) { Appointment appointment appointmentMapper.selectById(id); if (appointment null) { throw new BizException(预约记录不存在); } MapInteger, ListInteger fsm Map.of( AppointmentStatus.PENDING, List.of(AppointmentStatus.CONFIRMED, AppointmentStatus.CANCELED), AppointmentStatus.CONFIRMED, List.of(AppointmentStatus.COMPLETED, AppointmentStatus.CANCELED), AppointmentStatus.COMPLETED, List.of() ); if (!fsm.getOrDefault(appointment.getStatus(), List.of()).contains(targetStatus)) { throw new BizException(非法的预约状态流转); } appointmentMapper.updateStatus(id, targetStatus); } }代码说明create方法里先查房源因为预约要落在某个房源上屋主id来自房源表而不是前端传参防止租客伪造屋主id。transit把状态机放在Service层用Map表达“当前状态可到达哪些目标状态”。这样前端是否隐藏按钮都不影响后端安全接口即使被手动调用也跨不过状态机。Transactional放在实现类方法上当状态校验失败抛BizException时事务会一起回滚不会出现“校验报错但数据库状态已更新”的脏数据。参数上还要注意visit_time在 DTO 里用LocalDateTime前端传2026-05-20 10:30:00这种格式时在 Controller 用JsonFormat(pattern yyyy-MM-dd HH:mm:ss)做反序列化避免请求体里时间格式不统一导致 Jackson 报错。3.3 防止同一房源被重复下单的两种防护租客在同一个时间段对同一套房源下单如果不做约束就会出现两笔有效订单。最常见的两种做法第一种是数据库幂等在订单表加唯一约束ALTER TABLE rent_order ADD UNIQUE KEY uk_house_period (house_id, start_date, end_date);这样即使两个租客同时提交数据库也只会让一条插入成功另一条抛出DuplicateKeyException在Service层捕获后转成“该时间段房源已被预订”。第二种是乐观锁。房源表加version字段下单扣减可用状态时// 条件带 version返回值0 说明版本不匹配说明这个房源刚被改过 int updated houseMapper.updateByVersion(houseId, expectedVersion); if (updated 0) { throw new BizException(房源状态已变化请刷新后重试); }这两种方案的差别在于数据库唯一索引面对同一时刻的请求更强硬不会出现“后检查先插入”的窗口期乐观锁适合房源表经常改价的场景提示更友好但要求前端配合重试。租房系统的订单并发量不高我的习惯是先加唯一约束再配合Service层捕获重复键实现简单又不会有竞态问题。房源审核的代码其实比预约还简单——管理员审核后更新audit_status同步改房源的状态租客端查询房源时只放行audit_status 1的数据。审核动作建议在管理端列表里直接提供“审核通过/驳回”两个按钮驳回时要带原因这个原因存在独立字段而不是靠状态值表示。4. 前后端分离落地登录Token处理、统一响应与跨域4.1 SpringBoot侧的Token拦截器前后端分离后登录态不能再依赖Session常见做法是JWT或者Redis token。租房系统这种三端场景我一般用Redis token登录成功后把userId和role塞进Redis过期时间按租客端30分钟、管理端2小时分开设置拦截器只校验Redis里有没有这个key不需要解析签名。先注册拦截器和白名单Configuration public class WebConfig implements WebMvcConfigurer { private final TokenInterceptor tokenInterceptor; public WebConfig(TokenInterceptor tokenInterceptor) { this.tokenInterceptor tokenInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(tokenInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/house/public/**, /error); } }Component public class TokenInterceptor implements HandlerInterceptor { private final StringRedisTemplate redisTemplate; public TokenInterceptor(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { writeUnauthorized(response); return false; } String token header.substring(7); String userId redisTemplate.opsForValue().get(login:token: token); if (userId null) { writeUnauthorized(response); return false; } UserContext.set(UserContextDTO.of(Long.valueOf(userId), token)); return true; } private void writeUnauthorized(HttpServletResponse response) throws IOException { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\登录已过期\}); } }代码里三个细节容易被忽略OPTIONS请求直接放行。浏览器跨域预检请求不会带业务Header拦截器拦了会产生CORS错误。token在Redis里的key要带业务前缀login:token:避免和验证码、其他缓存key冲突。从Redis拿到的userId塞进UserContext这是一个ThreadLocal容器在拦截器里set在Controller/Service里get请求结束时在afterCompletion里remove防止线程池复用导致用户串号。4.2 Vue端请求拦截器对Token的统一处理前端部分贴一段最关键的请求/响应拦截器三端都复用同一份配置只是登录后路由菜单不同// src/utils/request.js import axios from axios import router from /router const service axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } ) export default service说明一下参数和选择理由VITE_API_BASE用环境变量指到后端地址开发指到本机8080生产指到Nginx代理地址不需要打包后改代码。请求拦截器统一加Authorization三端各自的页面不会重复写token逻辑。响应拦截器里401统一清掉本地token并跳登录页这个行为对管理端、屋主端、租客端一致如果管理端希望保留过期前的搜索条件可以在跳转前把当前路由query存sessionStorage。4.3 统一响应体和跨域配置前后端分离项目最容易出现前端解析失败的问题根源是后端返回格式不统一。约定一个最小响应体就够了public class ResultT { private int code; private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.msg ok; r.data data; return r; } public static T ResultT fail(int code, String msg) { ResultT r new Result(); r.code code; r.msg msg; return r; } }Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }参数说明要留意allowedOriginPatterns(*)和allowedOrigins(*)的区别是带credentials时allowedOrigins不能用星号而allowedOriginPatterns可以配合allowCredentials使用。管理端和屋主端如果用了带cookie的会话必须用前者。maxAge(3600)是预检请求缓存时长单位秒。设太短会导致每个接口都先发一次OPTIONS联调时看网络面板觉得请求“翻倍”先检查这里。生产环境把*收窄成具体的部署域名否则任何站点都能给后端发请求虽然token在Authorization头里相对安全但没必要留这个口子。5. 三端协同的横向能力通知公告、评价反馈与定时任务5.1 通知公告的可见范围用一张表搞定通知公告在三个端都有展示位管理端发全站通知、屋主端看平台规则、租客端看看房提醒。不要做三张表用一张表加 target_role 字段CREATE TABLE notice ( id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR(200) NOT NULL, content TEXT NULL, target_role VARCHAR(20) DEFAULT ALL COMMENT ALL/ADMIN/OWNER/RENTER, publish_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_target (target_role) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT公告通知表;查询端根据登录身份动态拼接条件// target_role ALL 或当前用户的角色 ListNotice list noticeMapper.selectList( new LambdaQueryWrapperNotice() .eq(Notice::getTargetRole, UserContext.getRole()) .or() .eq(Notice::getTargetRole, ALL) .orderByDesc(Notice::getPublishTime) );管理端发布公告时选“全体/管理端/屋主端/租客端”保存的是枚举字符串而不是数字索引数据库里直接能看懂。如果要做已读未读再建一张 notice_read 表存 userId noticeId首页红点就查这张表。5.2 评价反馈的防重逻辑评价反馈的核心约束是一个订单只能评价一次而且要等订单履行中后才能评价。用数据库唯一约束兜底但要把订单状态判断放在Service里ALTER TABLE feedback ADD UNIQUE KEY uk_order_feedback (order_id);// 提交评价前先查订单状态 Order order orderMapper.selectById(dto.getOrderId()); if (order null || order.getStatus() ! OrderStatus.IN_PROGRESS) { throw new BizException(当前订单状态不可评价); }这里有个租客端常见的误用只做“订单是否被评价过”的判断订单状态缺失导致已退租的订单也能补评。如果业务规则里允许“退租后7天内可补评”状态判断要改成IN_PROGRESS或FINISHED但不能直接开放给所有状态的订单。5.3 定时任务关掉超时未支付订单订单处理里最容易被忽略的是超时未支付。租客下单选了房源30分钟不付款房源一直处于被占状态别的租客看不了也约不了。用SpringBoot的定时任务做一个关闭兜底Component public class OrderTimeoutTask { private final OrderMapper orderMapper; public OrderTimeoutTask(OrderMapper orderMapper) { this.orderMapper orderMapper; } Scheduled(fixedDelay 60000) public void closeTimeoutOrders() { LocalDateTime threshold LocalDateTime.now().minusMinutes(30); int rows orderMapper.closeTimeoutOrders(threshold); if (rows 0) { log.info(自动关闭超时未支付订单 {} 笔, rows); } } }Update(UPDATE rent_order SET status 4 WHERE status 0 AND create_time #{threshold}) int closeTimeoutOrders(LocalDateTime threshold);参数说明fixedDelay 60000表示上次执行完再等60秒执行下一次适合不与业务高峰期抢资源的批量任务如果要求每天固定整点执行用cron 0 0 2 * * ?配合zone Asia/Shanghai指定时区。关闭条件里必须带status 0否则每次执行都会把已取消的订单再更新一次。规则时间写在SQL参数里而不是写死在Java侧方便后续把30分钟改成15分钟时只动一处。如果将来部署了多实例这个定时任务会在每个实例都跑一遍。需要加分布式锁或者退而求其次用上面的幂等更新——status带0条件多次执行不会重复改数据但会重复写日志。数据量大了再引分布式锁短期不用为这个优化过度设计。6. 从开发到交付版本选型、war包部署与联调排查清单6.1 SpringBoot 2.7 与 3.x 的选型和war包适配近两年新建项目经常卡在SpringBoot版本。3.x要求JDK17起生产环境Java如果还停留在8就应该选2.7.xJDK版本能升就选3.x依赖的javax命名空间要一并切到jakarta。外部容器部署时比如本地要用宝兰德这类国产中间件跑测试warSpringBoot默认jar包方式内嵌Tomcat就不适用。需要把打包方式改成war并把内嵌Tomcat标成providedpackagingwar/packaging dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId scopeprovided/scope /dependency主类同时继承 SpringBootServletInitializerSpringBootApplication public class SmartRentApplication extends SpringBootServletInitializer { public static void main(String[] args) { SpringApplication.run(SmartRentApplication.class, args); } Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(SmartRentApplication.class); } }这样打出来的war放进外部容器即可前置条件是容器内有对应版本的Servlet规范。外部容器部署时日志、配置、Redis地址都不要再写死在application.yml里用--spring.profiles.activeprod启动参数切profile。6.2 三端联调最容易出问题的三个点时间字段相差8小时。数据库连接串里加serverTimezoneAsia/ShanghaiJackson序列化时配置spring.jackson.time-zoneGMT8。如果前端传的是时间戳后端用LocalDateTime转换三步都做到位。打包后接口404。这是前后端分离部署最常见的坑前端把静态资源放到Nginx后只代理了/api到后端页面刷新访问非首页路由时交给后端后端没有对应页面就404。Nginx加location / { try_files $uri $uri/ /index.html; }接口暴露了不安全的监控端点。SpringBoot自带Actuator端点如果不对生产关闭巡检接口会被利用来抓内存转储等敏感信息。直接把management端点设为仅本机访问或者给Actuator加独立身份校验不要在公网暴露/actuator/heapdump这类端点。6.3 用三端账号把主链路走一遍部署完成后用一组带状态的账号验证整体流程比看单测更能发现问题# 屋主端登录提交房源 curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:owner01,password:owner123} # 租客端登录查询可预约房源 curl -X GET http://localhost:8080/api/house/public/list \ -H Authorization: Bearer token # 租客端创建预约屋主端确认租客端完成 curl -X POST http://localhost:8080/api/appointment/create \ -H Authorization: Bearer token \ -H Content-Type: application/json \ -d {houseId:1,visitTime:2026-05-20 10:30:00}验证时记录三端的预期结果管理端能看到房源待审核屋主端能看到预约单待确认租客端能对已履行订单提交评价。三条链路都走通后再检查状态机把“已取消的订单再次支付”这种异常请求打一遍后端必须返回业务异常而不是留给数据库报错。这几步做完这套基于SpringBoot的前后端分离租房系统的主干才算闭环。本文还有配套的精品资源点击获取
返回列表