☰
SpringBoot+Vue全栈项目:房产租赁系统从设计到部署全解析
2026/9/30 12:32:46 网站建设 项目流程

做毕设或者想独立带一个完整项目的话,房产租赁管理系统算是一个非常经典的SpringBoot+Vue选题。我帮人排查过好几个这类项目的运行问题,发现大多数翻车点都不在增删改查,而是租赁业务主线的关联设计:房源、预约、合同、账单这四类数据怎么串起来,状态怎么流转,三种角色怎么授权。这篇我按自己做项目的顺序来拆,从数据库建模讲到后端接口,再讲到Vue前端和联调部署,全程给可复用的代码和配置。正在做课程设计、准备毕设、或者单纯想练一个全栈项目的朋友,可以直接照着往下搭。

1. 为什么租赁系统会成为经典练手项目:需求主线与技术难点

1.1 一条租房交易链路串起全部功能

任何管理系统都要先谈业务主线,租赁系统的核心链路是“房源上架 -> 租客浏览 -> 预约看房 -> 房东确认 -> 签订合同 -> 按期生成账单 -> 账单缴纳”。这条链路几乎覆盖了中小型管理系统能遇到的所有常规需求:登录鉴权、角色权限、条件查询分页、多表关联、状态流转、文件上传、简单的流程审批。

实际做项目的时候很容易出现一种情况:拿到题目就开始写用户表和房源表,写到合同表就不知道怎么关联了。这是主线没理清楚导致的。正确的思路是顺着交易链路走——房源是系统里的标的物,预约和收藏是租客与房源之间的弱关系,合同是整个租赁关系的核心凭证,账单则是合同在时间维度上的金额实例。想通了这条线,表怎么建、接口怎么拆、页面怎么放,全都顺了。

1.2 三种角色的权限边界

系统按角色分成管理员、房东、租客三类,权限边界是非常明确的:

角色核心功能权限边界
租客搜索房源、收藏、预约看房、签订合同、查看自己的账单只能操作自己的数据
房东发布房源、处理预约、管理自己的房源和合同只能管理自己名下的房源
管理员审核房源、管理用户、查看全站合同与统计报表全站数据可见

这个表不只是在需求文档里写写而已,它直接决定了后端接口的写法。比如租客查合同列表,SQL里必须带tenant_id = 当前用户ID这个条件;房东改房源,Service层必须先判断house.landlord_id是否等于当前登录人。权限不是在页面上藏一个按钮就算完,后端接口每一层都要做校验。

1.3 容易被低估的状态流转设计

如果说权限分配还算常规,状态流转就是租赁系统真正的难点。房源有“待审核/已上架/已出租/已下架”四个状态,合同有“生效中/已到期/已终止”,账单有“未缴纳/已缴纳”。这些状态不是能随意乱跳的:

  • 待审核的房源不能被租客搜索到;
  • 已出租的房源不能再被预约;
  • 已下架的房源不能签合同;
  • 合同生效后,账单才会开始生成。

很多项目崩就崩在状态这里。一个典型场景:租客A预约了某套房,租客B在管理员审核通过之前就把这套房签了合同,这时候租客A的预约单就成了脏数据。避免这种问题只能靠 Service 层在关键操作里统一做状态判断,而不是依赖前端页面控制。这部分我后面写接口的时候会具体展开。

2. 建表先于写代码:按业务主线规划的9张核心表

2.1 为什么先建模而不是先写实体类

我见过不少人一上来就建SpringBoot工程,然后在实体类里随手写字段,写到一半发现缺字段又回头加列,结果表结构越来越乱。这里倒不是不能边写边改,但对租赁系统这种关联关系比较明确的业务,我更建议先把表定下来。

顺序是:先画业务流程图,再画ER图,再写建表SQL,最后才是Java实体类。数据库的表关系一旦定清楚,Controller怎么写、Service怎么拆、前端页面有哪些,基本都是水到渠成的事。建表阶段多花一小时,后面开发阶段能省下好几天改代码的时间。

2.2 核心表及关键字段设计

我给出一个实际能落地的方案,总共9张表:user、house、house_image、appointment、favorite、contract、bill、banner、notice。其中banner和notice属于锦上添花,核心是前7张。

用户表user:

CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT 'BCrypt密文', `nickname` varchar(50) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `role` tinyint NOT NULL DEFAULT 2 COMMENT '0-管理员 1-房东 2-租客', `status` tinyint NOT NULL DEFAULT 1 COMMENT '0-禁用 1-正常', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

三个细节需要注意:密码存BCrypt密文而不是明文;username必须有唯一索引;role用tinyint数字枚举,比存字符串省空间,前端映射也方便。

房源表house:

CREATE TABLE `house` ( `id` bigint NOT NULL AUTO_INCREMENT, `landlord_id` bigint NOT NULL COMMENT '房东ID', `title` varchar(100) NOT NULL COMMENT '房源标题', `address` varchar(255) NOT NULL COMMENT '详细地址', `region` varchar(50) DEFAULT NULL COMMENT '所在区域,用于筛选', `area` decimal(8,2) DEFAULT NULL COMMENT '面积', `price` decimal(10,2) NOT NULL COMMENT '月租金', `deposit` decimal(10,2) DEFAULT NULL COMMENT '押金', `bedroom_num` int DEFAULT 1, `livingroom_num` int DEFAULT 1, `toilet_num` int DEFAULT 1, `rent_type` tinyint NOT NULL DEFAULT 0 COMMENT '0-整租 1-合租', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0-待审核 1-已上架 2-已出租 3-已下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_landlord` (`landlord_id`), KEY `idx_region_price` (`region`, `price`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房源表';

为什么把区域单独设一列而不是直接用地址做LIKE '%...%'查询?因为前端通常会有个区域下拉框,等值匹配比模糊查询性能好一个量级,语义也更清晰。价格和面积都用DECIMAL,租金这种金额字段,用FLOAT/DOUBLE存迟早会出精度问题。

剩下的几张关联表在字段设计上同样有讲究。

房源图片表house_image:id, house_id, image_url, sort_order。图片独立建表而不是在房源表里存一个 JSON 数组,好处是方便做排序、批量删除,也方便以后扩展图床或者OSS。

预约看房表appointment:id, house_id, user_id, appoint_time, status, remark。状态建议用0-待确认 1-已确认 2-已取消 3-已完成,由房东来更新状态,模拟线下看房的确认流程。

收藏表favorite:id, house_id, user_id, create_time。这里必须要加一个(house_id, user_id)的唯一索引,不然用户反复点收藏按钮会生成重复数据。

合同表contract:id, contract_no, house_id, landlord_id, tenant_id, start_date, end_date, pay_type, monthly_rent, deposit, status, create_time。关键点是monthly_rent和deposit必须冗余一份到合同表里。因为房源表的价格后面是可以被房东修改的,而合同签订后的金额是法律意义上的约定,不能跟着房源表变动。

账单表bill:id, contract_id, bill_type, amount, due_date, status, pay_time。bill_type区分租金、水费、电费、物业费,due_date是到期日,status区分未缴纳和已缴纳。账单不需要用户手动创建,应该在合同生效时按租期自动生成,这个逻辑在Service层实现。

3. 后端拆解:JWT鉴权、分页查询与几个容易写歪的业务接口

3.1 分层结构与统一返回格式

后端工程用常规分层:controller、service、mapper、entity、dto、config、common。其中common放统一返回结果和异常处理。

统一返回结果长这样:

public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.setCode(500); r.setMessage(message); return r; } }

再配一个全局异常处理器,用@RestControllerAdvice捕获业务异常和参数校验异常,统一转成Result返回。这样前端axios拦截器只需要处理一种响应结构,不用每个接口单独判断。

3.2 登录鉴权:拦截器加JWT的轻量方案

Spring Security是一套完整的安全框架,功能强但配置复杂。对于租赁系统这种“登录 + 角色判断”的需求,我个人推荐用拦截器加JWT的轻量方案,代码量少,逻辑也直观,完全够用。

登录接口流程:

  • 根据username查用户,校验密码;
  • 密码用BCrypt加密存储,判断用BCryptPasswordEncoder.matches(明文, 密文);
  • 登录成功后生成JWT,payload里放入userId和role;
  • 前端把token存到localStorage,后续请求在请求头里带Authorization: Bearer xxx。

拦截器的核心逻辑:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; // 放行登录、注册等不需要鉴权的接口 if (handlerMethod.hasMethodAnnotation(PassToken.class)) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } // 解析并校验token,失败时直接抛401 Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } }

角色权限我是用自定义注解@RequireRole("landlord")加在Controller方法上实现的,拦截器里取出token里的role,跟注解要求的角色比对。这种实现方式对中小型项目来说足够清楚,也便于演示的时候讲明白权限控制的原理。

3.3 房源分页查询:动态条件怎么拼

房源列表页是前台流量最大的接口,支持关键字、区域、租金区间、户型、租住方式多个筛选条件。用MyBatis-Plus的LambdaQueryWrapper动态拼接条件,代码很干净:

public PageResult<HouseVO> queryHousePage(Integer pageNum, Integer pageSize, HouseQuery query) { Page<House> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<House> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(query.getRegion()), House::getRegion, query.getRegion()) .ge(query.getMinRent() != null, House::getPrice, query.getMinRent()) .le(query.getMaxRent() != null, House::getPrice, query.getMaxRent()) .eq(query.getBedroomNum() != null, House::getBedroomNum, query.getBedroomNum()) .eq(query.getRentType() != null, House::getRentType, query.getRentType()) .eq(House::getStatus, 1) // 前台只能看到已上架 .orderByDesc(House::getCreateTime); // 执行分页并转VO、补充首图 }

这里有个容易被忽略的细节:前台列表不应该查全量字段,图片、简介这种大字段最好延迟加载或者在VO里只保留需要展示的字段。我用了一个HouseVO来聚合房源信息和封面图,避免把整张表的字段全抛给前端。

3.4 三个容易写歪的业务接口

第一个是预约接口。用户在详情页点“预约看房”,后端不能直接插入一条记录就完事,必须先校验房源状态。我踩过的一个真实问题是:房源被房东下架了,但前端列表还残留着缓存,用户依然能点预约。所以在预约的Service方法里,第一步必须是查房源状态,只有status = 1(已上架)才允许预约,同时要检查用户是否已经预约过这套房。

第二个是合同签订接口。这是整个系统里事务性最强的接口,一个方法里要做完三件事:

  • 生成合同编号,格式建议HT + yyyyMMdd + 4位随机数;
  • 插入合同记录;
  • 根据start_date、end_date和pay_type自动生成多条账单记录。

这三步必须放在同一个@Transactional事务里,任何一步失败都要整体回滚。曾经见过有人把这套逻辑写在三个接口里让前端依次调用,结果前端调第二个接口失败,合同就变成了一个没有账单的“裸合同”。正确的做法是前端只调一次POST /api/contracts,后端把整条交易链路在一个事务里做完。

第三个是账单缴纳接口。缴纳时要校验账单状态(已缴纳的单子不能重复支付)、校验金额是否匹配,然后把status置为已缴纳并写入pay_time。这个接口通常是模拟支付,不需要真接第三方支付,但状态幂等性的处理逻辑还是要写清楚。

4. 前端拆解:Vue3路由、axios封装和富交互页面的落地方式

4.1 选Vue3而不是Vue2

现在新开项目我默认用Vue3 + Vite + Vue Router + Pinia + Element Plus。Vue3的组合式API对业务逻辑的聚合度比Vue2的选项式API高,一个功能模块的响应式数据和方法可以写在一起,不用在data、methods、computed之间来回跳。Vite开发服务器的启动速度和热更新也比Webpack时代的Vue CLI舒服很多。

4.2 路由结构与动态菜单

页面分成前台和后台两块。前台包括首页、房源列表、房源详情、登录注册;需要登录后才能访问的是个人中心(租客视角的收藏、预约、合同、账单)和租房管理(房东视角的房源发布、预约处理)。后台管理页面只开放给管理员,包括房源审核、用户管理、全站合同和统计。

这里的坑在于:如果所有路由都在前端静态配置,刷新页面时角色信息还没拿到,router.beforeEach里已做权限判断,但路由表里没有对应路径就会直接404。我采用的方案是:基础路由静态注册,角色相关路由在登录成功后根据user.role用router.addRoute('Layout', routeItem)动态追加,同时把菜单数据也按角色生成。逻辑类似:

const roleRoutes = { landlord: [ { path: '/my/houses', component: () => import('@/views/landlord/HouseList.vue') }, { path: '/my/house/add', component: () => import('@/views/landlord/HouseEdit.vue') } ], tenant: [ { path: '/my/favorites', component: () => import('@/views/tenant/Favorites.vue') }, { path: '/my/contracts', component: () => import('@/views/tenant/Contracts.vue') } ], admin: [ { path: '/admin/house-audit', component: () => import('@/views/admin/HouseAudit.vue') } ] }; // 登录成功后按角色追加 roleRoutes[user.role].forEach(route => { router.addRoute('Layout', route); });

刷新页面时,Pinia里的用户信息会丢失,需要重新拉取/api/user/info,等角色信息到位后再追加路由和菜单。这个流程处理好了,权限相关的体验就很顺畅。

4.3 axios封装:请求拦截器注入token

axios封装是所有前后端分离项目的标配。核心是请求拦截器带token,响应拦截器统一处理业务码和401:

const service = axios.create({ baseURL: '/api', timeout: 10000 }); service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); service.interceptors.response.use( response => { const { code, message, data } = response.data; if (code === 401) { localStorage.removeItem('token'); router.push('/login'); return Promise.reject(new Error(message)); } if (code !== 200) { ElMessage.error(message); return Promise.reject(new Error(message)); } return data; }, error => { ElMessage.error(error.response?.data?.message || '网络异常'); return Promise.reject(error); } );

这里有个体验优化:列表页的分页参数、筛选条件最好都放到URL的query里,比如/houses?region=xx&minRent=1000&pageNum=1。这样用户刷新页面后筛选条件不会丢,也方便把列表页链接分享出去。

4.4 核心页面逻辑

房源列表页:顶部一排筛选条件,下面分页网格。搜索关键字输入时加300ms防抖,不然每敲一个字就发一次请求,很容易把后端打挂。筛选条件变化时重置pageNum为1,再重新拉数据。

房源详情页:轮播图、价格、户型、地址、房东信息卡片,以及收藏和预约两个操作。收藏按钮的交互要做成“已收藏”和“未收藏”两种状态,加载详情时顺带查一下当前用户是否已收藏。预约弹窗里要选看房时间,提交后提示“等待房东确认”。

后台管理页面大量使用表格加对话框的形态,Element Plus的el-table、el-dialog、el-form组合起来效率很高。表单校验规则要提前定义好,手机号格式、租金必须大于0这类校验交给前端做一层,后端Service再做一层,双保险。

5. 联调与部署:跨域、图片上传、404问题和Docker打包

5.1 开发环境跨域问题

前后端分离项目开发时前端跑在5173端口,后端跑在8080端口,直接发请求必然跨域。最简单的处理是用Vite的代理配置:

// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

这样前端代码里所有请求都写成/api/xxx,开发环境下Vite帮你转发,生产环境下Nginx再转发一次。整个项目里不需要处理一次跨域头,这是最省心的方式。

5.2 图片上传与静态资源访问

房产系统里房源图片是核心数据,图片上传这块有不少细节。后端接收MultipartFile,保存到本地磁盘目录而不是数据库。我在application.yml里这么配置:

spring: servlet: multipart: max-file-size: 5MB max-request-size: 20MB web: resources: static-locations: file:${upload.path} upload: path: /data/upload/

这里的坑有几个。第一个是路径分隔符,Windows下是反斜杠,Linux下是正斜杠,不要硬编码,用Paths.get(uploadPath, fileName).toString()让系统自己处理。第二个是文件名不要用用户传来的原名,中文名和特殊字符都容易出问题,用UUID.randomUUID().toString() + 后缀重命名。第三个是文件类型校验,不能只信任扩展名,最好用Content-Type做一道过滤,防止上传恶意文件。

上传接口返回的URL我习惯做成/api/file/xxx.jpg这种形式,再走一遍后端接口去读文件,而不是直接返回磁盘绝对路径。绝对路径一旦服务器目录变了,前端就全部裂开。

5.3 部署时前端路由404

如果用Vue Router的history模式,打包部署到Nginx后,直接访问https://域名/house/1会返回404,因为Nginx找不到这个物理路径。解决办法是Nginx配置里加try_files:

location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

这个配置的意思是:如果请求的文件不存在,就回退到index.html,由前端路由接管。很多同学本地开发时一切正常,一部署就白屏或404,绝大多数是这个原因。

5.4 Docker-Compose组合部署

项目如果能用Docker编排起来,部署会很省事。我提供一个最小的组合:

# docker-compose.yml version: '3' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: house_lease ports: - "3306:3306" volumes: - ./sql:/docker-entrypoint-initdb.d backend: build: ./backend depends_on: - mysql ports: - "8080:8080" frontend: build: ./frontend depends_on: - backend ports: - "80:80"

后端Dockerfile用多阶段构建:先Maven打包,再把jar拷到JRE镜像里。前端Dockerfile基于nginx:alpine,把dist目录拷进去,再把前面那段Nginx配置覆盖进镜像。三个服务一条docker-compose up -d就能起全套,在答辩或演示场景下非常加分。

6. 项目从能跑到能演示:我实际踩过的坑与优化细节

6.1 SpringBoot版本选择有讲究

IDE新建SpringBoot项目默认会拉到当前最新稳定版,比如3.x。SpringBoot 3.x要求JDK17以上,而很多学校课程和网上教程还在用SpringBoot 2.6、2.7配合JDK8。如果你照着旧教程抄代码,突然发现spring.factories那套自动配置写法失效,或者javax.servlet变成了jakarta.servlet,大概率就是版本差异导致的。

我的建议是:如果没有特殊要求,直接选SpringBoot 2.7.x + JDK8/11,教程资料最多,踩坑最少;如果选3.x,就做好所有依赖都要用新版本的心理准备。版本选型和业务功能没关系,但它能决定你整个开发周期舒不舒服。

6.2 图片加载不出来的排查链路

这是联调阶段出现频率最高的问题。我遇到过的情况是:上传接口返回成功,数据库里也有记录,但前端图片一直转圈。完整的排查链路应该是这样的:

第一步,把图片URL单独放到浏览器地址栏访问,看返回什么。如果返回404,排查方向在后端静态资源映射或Nginx转发;如果返回403,通常是权限或文件被占用。

第二步,检查保存路径。我在Linux服务器上遇到过一个很隐蔽的问题:路径配置写的是/data/upload/,但应用是以非root用户运行的,/data目录没有写权限。上传接口一直没报错,是因为异常被吞了,文件根本没落到磁盘上。

第三步,检查Nginx有没有把/api/file/之类的路径代理到后端。如果只配了location /的try_files,一些静态文件请求会被前端路由接管而不是转发给后端。

第三点特别典型:前端访问https://域名/api/file/xxx.jpg,Nginx先匹配到location /,发现磁盘上没有这个物理文件,于是回退到index.html,返回了一个200状态的HTML页面,图片自然加载不出来。解决办法是在location /api/里把它代理到后端,让Spring的静态资源映射去处理。

6.3 前后端时间格式不一致

后端实体类用了LocalDateTime,默认序列化出来的是类似2025-03-20T10:30:00的格式,前端显示出来非常难看。统一配置一下Jackson的全局格式:

@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder -> { builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; } }

前端拿到统一格式的字符串后,Element Plus的日期组件直接就能解析,省掉一堆相互转换的麻烦事。

6.4 演示比写代码更重要的几件小事

最后聊几个能让项目演示更顺利的小技巧。第一,预置好三类测试账号:管理员、房东、租客,演示的时候直接一键登录,不要现场注册浪费时间。第二,房源数据要造得真实一些,户型、面积、租金、图片都符合逻辑,评委一眼扫过去觉得你做了充分的数据准备。第三,提前设计好两条演示路径:一条是租客视角的“浏览 -> 预约 -> 签约 -> 缴费”,另一条是房东视角的“上架房源 -> 处理预约 -> 查看合同”。沿着这两个闭环演示,整个系统的完整性一下就体现出来了。

第四,答辩或者演示之前,把项目里可能让你冷场的问题过一遍,比如“如果同一套房被两个人同时预约怎么办”、“房东下架房源后已签合同怎么处理”。这些问题在你开发的过程中已经遇到过,回答时把你当时的处理方案讲清楚,比背任何面试题都更有说服力。

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

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

立即咨询