做这个系统之前,我其实整理过好几版房屋交易类的项目。最早一版用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 | 中小型系统单体服务 |
| MyBatis | SQL可控,动态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 索引设计与查询优化
看到很多新手做项目时,索引就是在主键上看心情加几个,完全没有规划。这套系统里,房源列表是最高频的查询场景,也是数据量最大的场景,索引设计直接影响首页体验。
我的建议是先想清楚三个最常出现的查询条件:
- 按城市+区域筛选→
(city, district)联合索引 - 按价格区间排序→
price索引 - 按状态筛选+创建时间倒序→
(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那套重东西。
基本流程是这样的:
- 登录成功:根据userId和role生成Token,设置过期时间(建议2小时)。
- 前端存储:把Token存到localStorage,每次请求在Header里带
Authorization: Bearer <token>。 - 后端拦截:自定义
AuthInterceptor,校验Token合法性,从Token里解析出userId和role放到ThreadLocal或Request属性里。 - 细粒度权限:定义
@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 >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{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.jar5.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 上线部署轻量化方案
项目目录结构清晰的话,上线其实很简单。最省事的部署方案:
- 后端打成
jar包,用systemd或nohup在服务器上跑起来。 - 前端
npm run build生成dist静态目录。 - 用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解析器,这样中文关键词也能高效匹配。先跑通业务闭环,再针对性地优化检索链路,这才是一套房屋交易平台管理系统比较务实的演进路径。