☰
基于SpringBoot+Vue的房屋交易平台管理系统全栈实践
2026/10/15 7:14:00 网站建设 项目流程

做这个系统之前,我其实整理过好几版房屋交易类的项目。最早一版用JSP,维护成本高到让人崩溃,改一个房源状态要翻遍所有页面;后来换成前后端分离,又发现接口设计混乱,前端一个页面要拼三四个接口才能拿到完整数据。这次基于SpringBoot+Vue重写,整体清爽太多了。这套基于SpringBoot+Vue的房屋交易平台管理系统,配合MyBatis做数据层、MySQL做存储,可以说是一套很典型的Java全栈练手级架构——但"典型"不等于"简单",真要把它跑通、能用来支撑真实的看房预约和交易流程,里面还是有不少门道的。

如果你正准备找一套能实际部署的房屋交易平台源码,或者想自己动手从零搭一个类似系统,这篇会把这套方案的业务拆解、数据库设计、后端接口、前端交互、部署流程,以及我踩过的几个坑一次性讲清楚。

1. 房屋交易平台的核心业务与技术栈选型

1.1 业务闭环拆解:从看房到成交的四个角色

房屋交易平台跟普通的电商系统有个根本区别:电商的核心是"下单-支付-发货",而房屋交易平台的业务链路长得多,从房源发布、信息审核、用户筛选、预约看房、交易洽谈,到最后才能走到签约环节。整个过程没有真正的"物流",却比任何物流都依赖线下的撮合和信任。

一套完整的房屋交易平台管理系统,至少要覆盖四类使用者的诉求:

  • 买方用户:搜房源、看详情、收藏对比、预约看房、跟踪订单进度。
  • 卖方用户(房东):发布房源、维护房源信息(改价格、上下架)、查看自己被约看记录。
  • 房产经纪人/中介角色(可选):代客发布、跟进线索、整理成交数据。
  • 平台管理员:审核房源信息(这是交易平台的命门,虚假房源分分钟毁掉平台信任)、管理用户、处理投诉、统计运营数据。

我在设计这套系统时,把房源管理、信息审核、用户收藏、预约订单、成交记录这几条线拆成独立模块,模块之间通过状态字段关联。这样做的最大好处是,业务逻辑不会纠缠成一团,后面扩展"经纪人分成""成交佣金"等功能时,不需要动底层结构。

1.2 为什么选SpringBoot+Vue+MyBatis+MySQL这套组合

很多人在选型时纠结要不要上Spring Cloud微服务、要不要用云数据库,我的观点很直接:如果项目规模在几个G以内、用户量没到百万级,单体应用+关系型数据库是最合适的。微服务带来的服务治理成本、分布式事务成本,在小项目里完全是负担。

SpringBoot的价值在于开箱即用。你不需要像传统SSH那样去配一大堆XML,一个SpringBoot工程启动内嵌Tomcat,直接把业务跑起来。配合MyBatis做数据持久化,SQL掌握在自己手里,稍微复杂一点的多表关联、动态条件检索都可以精确控制,这在租房/售房平台的搜索场景里特别顺手。

Vue这边,我建议直接用Vue 3 + Vite + Element Plus。Element Plus的表单组件非常成熟,做后台管理类页面基本是"开箱即用"。

这套组合最适合谁?课程设计要做毕设的学生、准备上手SpringBoot全栈的开发者、以及需要快速交付一套内部管理系统的团队。它不是最前沿的技术,但胜在稳妥、资料多、遇到问题搜得到。

技术栈选型理由适用场景
SpringBoot 2.7 / 3.x约定大于配置,生态成熟,内嵌Tomcat中小型系统单体服务
MyBatisSQL可控,动态SQL能力强多条件筛选、报表类查询
MySQL 8.x使用广泛、性价比高、支持JSON字段关系型核心数据存储
Vue 3 + Vite构建快、响应式开发体验好、TS兼容前后端分离的前端工程
Element Plus后台组件库完整表单、表格、弹窗等中后台界面

2. 数据库设计:六张核心表撑起交易闭环

2.1 核心表结构与字段设计

数据库设计我从来都是按"业务主链路"来推表,而不是一上来就照着需求文档搬字段。这套系统的主链路是:用户注册登录→卖方发布房源→管理员审核→买方浏览/检索→收藏/预约→订单流转→成交。

沿着这条链路,下面这六张表就是绝对的核心:

用户表(t_user)

CREATE TABLE `t_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT '密码(BCrypt加密存储)', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `role` TINYINT NOT NULL DEFAULT 1 COMMENT '角色:1买家 2卖家 3经纪人 99管理员', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

字段设计上有两个细节要提一下。第一,密码一定不要明文存,用BCrypt加密,虽然这类系统常有"测验性质",但养成习惯很重要。第二,角色字段用数字常量,扩展角色时不用改表结构,只需要在Java代码里定义常量类。

房源表(t_house)是全系统的核心大表,字段比较多,我挑重点说:

  • title、description:房源标题和描述。
  • cover_image:封面图URL,列表页直接展示用。
  • images:轮播图URL集合,用逗号分隔或者JSON字符串。
  • price:售价/租金(单位用分或元需要定好,避免前端浮点精度问题)。
  • area、bedroom_num、livingroom_num、bathroom_num:面积和户型。
  • house_type:房屋类型(1整租 2合租 3二手房 4新房)。
  • province、city、district、address:地区四级地址。
  • publish_user_id:发布人ID。
  • status:1待审核 2在售 3已下架 4已成交 5审核不通过。

这里最容易被忽略的是status字段之外一定要有view_count或like_count这类冗余计数。真实的房源详情页会高频查询浏览量、收藏量,如果每次都count关联表,数据一大就会拖慢详情接口。

预约订单表(t_appointment)

CREATE TABLE `t_appointment` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `house_id` BIGINT NOT NULL COMMENT '房源ID', `buyer_id` BIGINT NOT NULL COMMENT '预约买家ID', `appointment_time` DATETIME NOT NULL COMMENT '期望看房时间', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待确认 1已确认 2已完成 3已取消', `remark` VARCHAR(255) DEFAULT NULL COMMENT '预约备注', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_house` (`house_id`), KEY `idx_buyer` (`buyer_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='看房预约表';

预约表看起来简单,其实这是并发问题最集中的地方。同一套房源不能同时被两个买家预约同一个时间段,这个约束必须在数据库层面或应用层加锁处理,不能只靠前端按钮禁用,后面章节我会细说。

2.2 状态机设计与业务约束

我把这套系统的核心状态机梳理成两条线,这也是整个系统设计里最关键的部分。

房源状态流:待审核 → 在售 → 已下架 / 已成交,审核不通过可以直接从待审核跳到驳回。这是一个有向图,不是简单的枚举值,因为"已成交"这个状态只能从"在售"变过来,不可能从"待审核"瞬间变成"已成交"。

订单状态流:待确认 → 已确认 → 已完成 / 已取消。

在Java代码里,我不会在每个Service方法里散落着一堆if (house.getStatus() == 2) { ... },而是定义一个HouseStatusEnum枚举,把状态流转规则封装成方法:

public enum HouseStatus { PENDING(1, "待审核"), ON_SALE(2, "在售"), OFF_SHELF(3, "已下架"), SOLD(4, "已成交"), REJECTED(5, "审核不通过"); private final int code; private final String desc; public boolean canTransferTo(HouseStatus target) { // 用邻接表定义合法流转关系 switch (this) { case PENDING: return target == ON_SALE || target == REJECTED; case ON_SALE: return target == OFF_SHELF || target == SOLD; default: return false; } } }

这样做的好处非常实际:后续加需求(比如"已下架"可以"重新上架")时,你只需要在枚举里改一处,而不是在十个Service方法里找散落的判断。

2.3 索引设计与查询优化

看到很多新手做项目时,索引就是在主键上看心情加几个,完全没有规划。这套系统里,房源列表是最高频的查询场景,也是数据量最大的场景,索引设计直接影响首页体验。

我的建议是先想清楚三个最常出现的查询条件:

  1. 按城市+区域筛选→(city, district)联合索引
  2. 按价格区间排序→price索引
  3. 按状态筛选+创建时间倒序→(status, create_time)联合索引
ALTER TABLE `t_house` ADD INDEX `idx_city_district` (`city`, `district`), ADD INDEX `idx_price` (`price`), ADD INDEX `idx_status_time` (`status`, `create_time`);

这里要特别说明一个经典的误区:不要对每个字段单独加索引。比如你已经建了idx_status_time(status, create_time),就没必要再单独建idx_status(status),联合索引的最左前缀原则决定了前者完全能覆盖后者的场景。单独加一堆索引除了浪费磁盘空间,还会拖慢写入速度。


3. 后端核心实现:SpringBoot分层设计与关键接口

3.1 项目分包与统一响应封装

拿到一套SpringBoot源码,我第一件事不是跑起来,而是看它的包结构。包结构混乱的项目,代码质量通常也堪忧。合理的分层应该一眼能看出"请求从哪进来、业务在哪处理、数据在哪读写":

com.example.housetrade ├── controller # 接口层:接收参数、返回结果 ├── service # 业务层:处理业务逻辑、事务控制 ├── mapper # 持久层:MyBatis接口 ├── entity # 数据库实体 ├── dto # 入参/出参对象 ├── common # 统一返回结果、异常、工具类 └── config # 配置类、拦截器

统一返回结果(Result<T>)我特别强调一下。看到很多项目里接口一会儿返回Map一会儿返回List,前端根本没法统一处理错误。我用的结构是:

public class Result<T> { private Integer code; // 200成功 500失败 private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.code = 500; result.message = message; return result; } }

配合全局异常处理器@RestControllerAdvice,不管业务异常还是System Exception,前端拿到的永远是统一格式。这一点对前后端分离的项目太重要了,否则前端封装axios的拦截器时处处都是坑。

3.2 登录鉴权:JWT + 拦截器权限控制

大多数房屋交易平台的接口不是所有用户都能调的。比如"房源审核"只有管理员能调,"下架房源"只有房主和超管能调。这块我用的方案是JWT + 拦截器 + 自定义注解,不需要引入Spring Security那套重东西。

基本流程是这样的:

  1. 登录成功:根据userId和role生成Token,设置过期时间(建议2小时)。
  2. 前端存储:把Token存到localStorage,每次请求在Header里带Authorization: Bearer <token>。
  3. 后端拦截:自定义AuthInterceptor,校验Token合法性,从Token里解析出userId和role放到ThreadLocal或Request属性里。
  4. 细粒度权限:定义@RequireRole(role = 99)注解,在需要管理员权限的Controller方法上标注,拦截器里做角色判断。

这里容易踩的坑是Token过期后的用户无感刷新。简单方案是前端axios响应拦截器里,当遇到401状态码时,跳转登录页并弹出"登录已过期,请重新登录"。更好的方案是后端提供一个refreshToken机制,但那样复杂度会明显提升,小系统不建议做太重。

3.3 房源发布与检索的动态SQL实现

房源检索是这套系统里MyBatis动态SQL最有价值的地方。前端的筛选条件可能组合出几十种情况,但你不能为每一种组合写一个SQL——这时候就是<if>标签的天下。

<select id="searchHouseList" resultType="com.example.housetrade.entity.House"> SELECT * FROM t_house <where> <if test="city != null and city != ''"> AND city = #{city} </if> <if test="district != null and district != ''"> AND district = #{district} </if> <if test="minPrice != null"> AND price &gt;= #{minPrice} </if> <if test="maxPrice != null"> AND price &lt;= #{maxPrice} </if> <if test="houseType != null"> AND house_type = #{houseType} </if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%')) </if> AND status = 2 <!-- 只搜索在售房源 --> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>

这里有一个很多新手会犯的错误:在<if>外面手写WHERE 1=1。看着方便,实际上完全没必要,<where>标签会自动去掉第一个多余的AND。还有一点,排序字段不要直接拼SQL,比如orderBy如果来自前端传参,很容易被SQL注入。稳妥做法是在Java代码里用白名单校验。

3.4 交易订单的创建与并发控制

到了订单环节,要考虑的问题就明显变多了。重复预约是最典型的并发问题——两个买家同时看中同一套房源,同时提交预约,如果后端不做控制,数据库里就会产生两条同一时段的预约记录。

我的处理方式比较简单但又稳定:

@Transactional public Appointment createAppointment(Long houseId, Long buyerId, LocalDateTime time) { // 使用 SELECT ... FOR UPDATE 锁住房源记录 House house = houseMapper.selectByPrimaryKeyForUpdate(houseId); if (house == null || house.getStatus() != 2) { throw new BusinessException("房源不存在或已下架"); } // 检查该房源、该时间段是否已有其他预约 int count = appointmentMapper.countByHouseAndTime(houseId, time, null); if (count > 0) { throw new BusinessException("该时间段已被预约,请选择其他时间"); } Appointment appointment = new Appointment(); appointment.setHouseId(houseId); appointment.setBuyerId(buyerId); appointment.setStatus(0); appointmentMapper.insert(appointment); return appointment; }

注意houseMapper.selectByPrimaryKeyForUpdate对应的SQL是SELECT * FROM t_house WHERE id = #{id} FOR UPDATE。这个行锁保证同一时间只有一个线程能对这套房源做预约操作。但这块有代价:持有锁期间事务不能太长,如果你在锁里面去做远程调用、发短信之类的操作,会让整个接口变得很慢。实际项目里短信通知放到事务提交后再异步发,就是基于这个考虑。


4. 前端实现:Vue 3 + Element Plus 搭建交互页面

4.1 前端项目结构与路由守卫

前端这部分,我默认你用的是Vite创建Vue3工程,装好vue-router、pinia、axios、element-plus。项目结构建议这样划分:

src ├── api # 接口请求模块 ├── components # 通用组件(图片上传、分页等) ├── router # 路由配置 ├── stores # Pinia状态管理 ├── views # 页面 │ ├── home # 首页/房源列表 │ ├── house # 房源详情/发布 │ ├── user # 个人中心/我的预约 │ └── admin # 后台管理 └── utils # 封装axios等工具

路由守卫是前端鉴权的核心。Vue Router的beforeEach钩子里,判断目标路由是否要求登录、当前用户角色是否匹配:

router.beforeEach((to, from, next) => { const userStore = useUserStore(); const token = userStore.token || localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); return; } if (to.meta.role && userStore.userInfo.role !== to.meta.role) { next({ path: '/403' }); return; } next(); });

记住:路由守卫只是用户体验层面的"门禁",真正的安全校验必须在后端接口做。前端隐藏了按钮不代表接口不能被恶意调用。

4.2 核心页面与组件交互

这套系统的核心页面其实是三个:房源列表页(带筛选)、房源详情页(带预约弹窗)、后台审核页。

房源列表页我用Element Plus的el-form+el-row做成筛选器,筛选条件绑定响应式对象,点击"搜索"按钮后调用api/searchHouseList。这里有个性能小技巧:筛选条件变化时加一个300ms的防抖,防止用户连续点击快速筛选造成请求轰炸。

房源详情页最核心的组件是"预约看房弹窗",里面除了选择时间,还有一个关键逻辑:如果该房源当前用户已经预约过,就禁用按钮并提示"您已预约,请等待确认"。这个判断需要后端返回一个hasAppointed字段,而不是让前端自己去对比历史记录。

后台审核页相对简单,就是一个表格 + 审核弹窗。但要注意:表格卡片和详情数据最好用懒加载方式获取,图片懒加载、分页懒加载,避免后台页面打开时一下加载几百张高清图把浏览器拖垮。

4.3 axios统一封装与状态管理

axios封装我建议所有项目统一处理三件事:请求带上token、响应统一解密、401统一跳转。

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

状态管理上,我只把用户信息和登录状态放Pinia,其他业务数据尽量在组件内维护或者通过props传递。千万不要把整个房源列表也塞进Pinia,那样会导致状态过于臃肿,页面刷新后数据丢失还得重新拉取,不如让每个页面自己管理数据。


5. 从零到一部署运行:本地启动与上线要点

5.1 环境准备与数据库初始化

先把环境准备好。以下是我本地使用的版本,兼容性和踩坑最少:

  • JDK 8(如果SpringBoot是2.7)或 JDK 17(SpringBoot 3.x)
  • Maven 3.6+
  • MySQL 8.0+
  • Node.js 16+ / 18+
  • 前端包管理器 npm 或 pnpm

准备好环境后,最重要的一件事是初始化数据库。把项目里的sql/house_trade.sql(或类似文件名)导入MySQL:

mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS house_trade DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -u root -p house_trade < house_trade.sql

注意:初始化脚本里如果有中文字段注释,保证文件编码是UTF-8,否则导入时会出现乱码。Windows下用Navicat导入时也建议先转成UTF-8再执行。

5.2 后端配置与启动

打开后端项目的application.yml,把数据库账号密码改成自己的:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/house_trade?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.housetrade.entity configuration: map-underscore-to-camel-case: true

有一个配置极其关键:map-underscore-to-camel-case设为true。数据库字段是create_time,Java实体是createTime,如果没有这个配置,MyBatis查出来的结果字段全是空的,项目就是"看起来能启动但实际全绿屏"。

后端启动:

mvn spring-boot:run

或者先打包再跑:

mvn clean package -DskipTests java -jar target/house-trade-0.0.1-SNAPSHOT.jar

5.3 前端启动与联调

前端启动前要确认接口代理配置。在Vite项目中,vite.config.js里配置:

export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })

这里有个很关键的约定:前端所有请求都以/api开头,代理到后端时再把/api前缀去掉。举个例子,前端请求/api/house/list,后端接口实际路径是/house/list(Controller上没加/api前缀)。这个设计解决了前后端分离环境下跨域问题,又不需要后端额外配CORS。

npm install npm run dev

浏览器访问http://localhost:3000,正常就能看到首页了。

5.4 上线部署轻量化方案

项目目录结构清晰的话,上线其实很简单。最省事的部署方案:

  1. 后端打成jar包,用systemd或nohup在服务器上跑起来。
  2. 前端npm run build生成dist静态目录。
  3. 用Nginx托管dist目录,并把/api反向代理到后端jar包的8080端口。

Nginx关键配置(部分):

server { listen 80; server_name your-domain.com; root /home/www/house-trade/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

上面try_files配置为什么重要?因为Vue是History模式,直接访问/house/123这样的详情页路径时,Nginx找不到对应的物理文件,必须让它回退到index.html再由前端路由接管,否则一刷新页面就是404。


6. 实战避坑:我开发这套系统时踩过的6个坑

6.1 时间字段时区不一致

现象:后端存进数据库的create_time比本地时间晚了8个小时。

原因:Java服务默认时区和MySQL连接串里的serverTimezone没设置,或者两者取到了不同时区。

解决:在application.yml的JDBC连接串上明确加serverTimezone=Asia/Shanghai,同时JVM启动参数加上-Duser.timezone=Asia/Shanghai。这一条配置好能省掉后面所有时间相关排查。

6.2 MyBatis动态SQL的常见Bug

<if>标签判断字符串相等时,不要写成<if test="city == '北京'">。MyBatis在OGNL表达式里会把单个'北京'当成字符而不是字符串,匹配永远为false。正确写法是city == '北京'.toString(),或者在Java代码里直接传入一个常量标记。

6.3 前端跨域请求Session丢失

前后端分离项目里,如果你在Controller里依赖HttpSession保存登录状态,一旦前端通过代理访问,Session的Cookie可能因为跨域策略无法写入,导致"登录成功但下一个请求又要求登录"。这也是我整套系统改用JWT而不是Session的核心理由——Token在头里走,天然不受跨域限制。

6.4 并发场景下的重复预约

前面讲订单创建时提到的SELECT ... FOR UPDATE,实际开发中不止一次看到有人忽略这个问题。如果你对并发要求不高,也可以用"数据库唯一约束"来做兜底防御,比如给t_appointment加一个house_id + appointment_time + status的唯一索引(但要注意已取消的订单会堵住时间点)。稳妥做法还是锁+唯一约束双保险。

6.5 图片上传后无法回显

很多项目把图片上传到本机磁盘的某个目录,返回给前端的却是相对路径或localhost地址。部署到服务器后,前端页面根本没访问服务器磁盘的权限,图片自然裂掉。

正确做法:要么上传到对象存储(云OSS类服务)拿到公网URL,要么在Nginx里配置一个静态资源映射目录:

location /upload/ { alias /home/www/house-trade/upload/; }

后端保存的URL路径只留/upload/xxx.jpg,前端访问时由当前域名拼接补全,这样换域名或迁移服务器时不用改数据库里的图片路径。

6.6 列表页大数据量卡顿

房源数量一旦上千,前端一次性渲染所有DOM就会明显卡顿。我的建议是分页控制在每页10-20条,图片用懒加载(v-lazy或Element Plus的el-image自带懒加载)。另外在SQL层面记得用LIMIT限制分页,不要LIMIT 100000, 20这样深翻页,深翻页可以用"上一页最后一条的id"来替代offset。


最后再分享一个小技巧。在开发房屋交易这类系统时,房源检索的关键词模糊匹配建议后期升级为倒排索引或全文索引,不要只靠LIKE '%keyword%',数据量超过5万条后那种查询性能下降会非常明显。我个人的做法是用MySQL自带的全文索引加上ngram解析器,这样中文关键词也能高效匹配。先跑通业务闭环,再针对性地优化检索链路,这才是一套房屋交易平台管理系统比较务实的演进路径。

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

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

立即咨询