简介:这是一套面向房产中介创业者、房产门户运营方及二次开发者的百分百开源房产系统源码,可一站式搭建类似贝壳、链家、58、安居客的房产电商平台。系统覆盖PC端与手机端,内置新房、二手房、出租房、小区、问答等模块,并支持内外网ERP与多区域分站运营,通过子域名绑定即可快速跨区域扩张,同时兼容自主运营与加盟运营相结合的模式。资源包共5701个文件,以js、html、php、css等前后端源码为主体,辅以gif、png、jpg等图片素材及less、scss样式文件,另有json、yml、sql等配置与数据库脚本,压缩包约34.66MB,目录结构完整,便于按模块检索与二次开发。目前已有2376人学习下载,适合需要快速搭建房产信息平台、研究房产电商系统架构或进行定制化改造的开发者参考,可从中获取多端业务逻辑、分站权限分配与加盟管理思路。
1. 仿贝壳百分百开源房产系统网站:从零搭一套能跑通的房源平台
二手房挂牌、房源详情页、地图找房、经纪人带看记录——这些模块在房产系统里几乎是标配。但真正自己动手搭一套「仿贝壳百分百开源房产系统网站」时,很多人卡在第一步:数据模型怎么设计,房源、小区、经纪人、带看记录之间怎么关联,前端筛选条件怎么和后端索引对上。我见过不少团队用通用 CMS 硬改,结果房源列表页加载三秒起步,筛选组合一多直接超时。
这套系统的核心价值在于:把房产交易里最重的几个数据实体(房源、小区、商圈、经纪人、用户、带看)用合理的关系模型串起来,再配一套能扛住多条件组合筛选的查询层。适合两类人:一是想自建房产平台的中小团队,二是想拿一个真实业务场景练全栈的开发者。下面按「数据建模 → 后端接口 → 前端筛选 → 搜索优化 → 避坑」的顺序拆开讲,每一步都给可复现的代码和参数。
2. 房源数据模型怎么设计:六张核心表与三个关系陷阱
2.1 为什么不能把房源信息塞进一张宽表
房产系统最典型的翻车点,就是一开始图省事,把房源的所有属性——面积、户型、楼层、朝向、装修、小区名、商圈、地铁距离、挂牌价、成交价——全塞进一张house表。前两周跑得挺顺,等到要按「商圈 + 户型 + 价格区间 + 地铁距离」组合筛选时,查询条件一多,索引根本建不过来。
正确的做法是拆成六张核心表:house(房源主表)、community(小区表)、district(区域商圈表)、agent(经纪人表)、user(用户表)、appointment(带看记录表)。房源主表只存和房源本身强相关的字段,小区和商圈信息通过外键关联。这样小区改名、商圈调整时,不用动房源数据。
-- 房源主表:只存房源自身属性 CREATE TABLE house ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, community_id BIGINT NOT NULL COMMENT '关联小区', agent_id BIGINT NOT NULL COMMENT '维护经纪人', layout VARCHAR(50) COMMENT '户型,如3室2厅', area DECIMAL(8,2) COMMENT '建筑面积', floor INT COMMENT '所在楼层', total_floor INT COMMENT '总楼层', orientation VARCHAR(20) COMMENT '朝向', decoration VARCHAR(20) COMMENT '装修程度', price DECIMAL(12,2) COMMENT '挂牌总价', unit_price DECIMAL(10,2) COMMENT '单价', status TINYINT DEFAULT 1 COMMENT '1在售 2已售 3下架', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_community (community_id), INDEX idx_status_price (status, price), INDEX idx_agent (agent_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段建表语句里,idx_status_price是给「在售房源按价格排序」这个最高频查询准备的联合索引。注意status放前面,因为筛选时状态几乎必带,而价格区间是范围查询,放后面才能用上索引。unit_price单独存一列而不是每次用price/area算,是为了让「按单价排序」也能走索引。
2.2 小区表和区域表的层级关系
小区和区域的关系是房产系统里最容易设计错的地方。常见错误是小区直接挂一个「商圈」字段,但实际业务里,一个小区可能横跨两个商圈,一个商圈又属于一个行政区。我一般会拆成district(行政区)和biz_area(商圈)两张表,小区通过biz_area_id关联商圈,商圈再关联行政区。
CREATE TABLE district ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT '行政区名', city_code VARCHAR(20) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE biz_area ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT '商圈名', district_id BIGINT NOT NULL, INDEX idx_district (district_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE community ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, biz_area_id BIGINT NOT NULL, address VARCHAR(200), build_year INT COMMENT '建成年份', total_house INT COMMENT '总户数', avg_price DECIMAL(10,2) COMMENT '小区均价,定时任务更新', INDEX idx_biz_area (biz_area_id), INDEX idx_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;community表里的avg_price是冗余字段,由定时任务每天凌晨根据在售房源重新计算。这样做的好处是小区列表页不用每次去聚合房源表,直接读缓存字段。代价是数据有延迟,但对房产场景来说,小区均价延迟一天完全可接受。
2.3 带看记录表:时间线和状态机
带看记录是房产系统里状态流转最复杂的部分。一条带看记录要经历「预约 → 确认 → 带看中 → 已完成 → 已取消」几个状态,而且用户和经纪人都可能取消。设计时不要用多个布尔字段(is_confirmed、is_cancelled),而是用一个status字段加状态机。
CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, house_id BIGINT NOT NULL, user_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, appoint_time DATETIME NOT NULL COMMENT '预约带看时间', status TINYINT DEFAULT 1 COMMENT '1待确认 2已确认 3已完成 4已取消', cancel_reason VARCHAR(200), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_agent_time (agent_id, appoint_time), INDEX idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_agent_time这个联合索引是给经纪人查「我明天有哪些带看」用的,按经纪人 ID 过滤后按时间排序,走一个索引就够。状态字段用 TINYINT 而不是 ENUM,是因为后续加状态时改 ENUM 要锁表,TINYINT 加个注释就行。
3. 后端接口怎么写:房源列表分页与多条件筛选
3.1 用 MyBatis 动态 SQL 拼筛选条件
房源列表接口是整个系统压力最大的地方。用户可能同时传商圈、价格区间、户型、朝向、装修、关键词六个条件,还可能按价格或单价排序。用 MyBatis 的<where>和<if>动态拼 SQL 是最稳的做法,但要注意防止条件为空时全表扫描。
<select id="selectHousePage" resultMap="HouseVOMap"> SELECT h.id, h.title, h.layout, h.area, h.price, h.unit_price, c.name AS community_name, b.name AS biz_area_name FROM house h JOIN community c ON h.community_id = c.id JOIN biz_area b ON c.biz_area_id = b.id <where> h.status = 1 <if test="bizAreaId != null"> AND c.biz_area_id = #{bizAreaId} </if> <if test="minPrice != null"> AND h.price >= #{minPrice} </if> <if test="maxPrice != null"> AND h.price <= #{maxPrice} </if> <if test="layout != null and layout != ''"> AND h.layout = #{layout} </if> <if test="orientation != null and orientation != ''"> AND h.orientation = #{orientation} </if> <if test="keyword != null and keyword != ''"> AND c.name LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY <choose> <when test="sort == 'price_asc'">h.price ASC</when> <when test="sort == 'price_desc'">h.price DESC</when> <when test="sort == 'unit_price_asc'">h.unit_price ASC</when> <otherwise>h.created_at DESC</otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>这段 SQL 的关键点:h.status = 1写死在<where>里而不是放在<if>里,保证永远只查在售房源,避免条件全空时把下架房源也扫出来。ORDER BY用<choose>而不是直接拼字符串,防止 SQL 注入。LIMIT的offset由前端传页码、后端计算,不要在前端算好传过来,否则容易被改。
3.2 分页参数与深分页问题
分页参数一般用page和pageSize,后端算offset = (page - 1) * pageSize。pageSize要设上限,我一般限制最大 50,防止有人传pageSize=10000把数据库拖垮。
深分页是另一个坑。当用户翻到第 100 页时,LIMIT 9900, 20要先扫描 9900 行再丢掉,性能急剧下降。房产系统里用户很少翻那么深,但爬虫会。常见做法是限制最大页码,比如只允许翻到前 50 页,或者用「上一页最后一条的 ID」做游标分页。
// 游标分页:传 lastId 而不是 page public List<HouseVO> selectByCursor(Long lastId, int pageSize, HouseQuery query) { if (lastId == null) { lastId = Long.MAX_VALUE; } // SQL 里加 AND h.id < #{lastId} ORDER BY h.id DESC LIMIT #{pageSize} return houseMapper.selectByCursor(lastId, pageSize, query); }游标分页的代价是不能跳页,只能一页页往下翻。房产列表页通常就是无限滚动,所以这个代价可以接受。如果产品坚持要跳页,那就限制最大页码,别硬扛深分页。
3.3 房源详情接口的缓存策略
房源详情页的 QPS 远高于列表页,因为每个用户点进详情都会请求一次。详情数据变化不频繁,适合加缓存。我一般用 Redis 缓存房源详情,key 是house:detail:{id},过期时间设 10 分钟。
public HouseDetailVO getDetail(Long houseId) { String key = "house:detail:" + houseId; String cached = redisTemplate.opsForValue().get(key); if (cached != null) { return JSON.parseObject(cached, HouseDetailVO.class); } HouseDetailVO detail = houseMapper.selectDetailById(houseId); if (detail != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(detail), 10, TimeUnit.MINUTES); } return detail; }缓存更新策略用「删除缓存」而不是「更新缓存」,因为更新缓存时如果并发写,可能写入旧值。房源信息变更时,直接redisTemplate.delete(key),下次请求自然回源。注意缓存穿透问题:如果查一个不存在的房源 ID,每次都打到数据库。解决办法是缓存空值,设一个较短的过期时间,比如 1 分钟。
4. 前端筛选与地图找房:条件联动和性能取舍
4.1 筛选条件的状态管理
前端筛选区通常有商圈、价格、户型、更多筛选四个维度。用户点选后要立刻刷新列表,同时 URL 要同步,方便分享和回退。用 Vue 或 React 都可以,核心是把筛选条件统一成一个对象,任何变更都走同一个fetchList方法。
// 筛选条件统一对象 const filters = reactive({ bizAreaId: null, minPrice: null, maxPrice: null, layout: '', orientation: '', sort: 'default', page: 1 }); // 任何筛选变更都调这个 async function fetchList() { const params = { ...filters }; // 去掉空值,避免后端拼无用条件 Object.keys(params).forEach(k => { if (params[k] === null || params[k] === '') delete params[k]; }); const res = await axios.get('/api/house/list', { params }); list.value = res.data.records; total.value = res.data.total; } // 监听筛选变化,重置页码 watch(() => ({ ...filters }), () => { filters.page = 1; fetchList(); }, { deep: true });这段代码里,watch深度监听filters,任何字段变化都触发fetchList,同时把page重置为 1。注意watch里用{ ...filters }展开,是为了让 watch 能检测到对象内部变化。实际项目里更推荐用watchEffect或手动在每次筛选点击时调fetchList,避免深度监听带来的性能开销。
4.2 地图找房:聚合与按视野查询
地图找房是房产系统的招牌功能。核心逻辑是:地图移动或缩放后,把当前视野的经纬度范围传给后端,后端返回该范围内的房源聚合点。聚合点按网格划分,每个网格显示房源数量,放大到一定级别后显示具体房源。
// 地图移动结束后触发 map.on('moveend', () => { const bounds = map.getBounds(); const params = { minLng: bounds.getWest(), maxLng: bounds.getEast(), minLat: bounds.getSouth(), maxLat: bounds.getNorth(), zoom: map.getZoom() }; fetchMapHouses(params); }); async function fetchMapHouses(params) { const res = await axios.get('/api/house/map', { params }); // res.data 是聚合点数组,每个点有 lng, lat, count renderMarkers(res.data); }后端按zoom决定聚合粒度:zoom 小于 12 时按商圈聚合,12 到 15 按网格聚合,大于 15 返回具体房源。网格聚合可以用 geohash 实现,把经纬度转成 geohash 字符串,按前缀分组统计。
-- 按 geohash 前 5 位聚合 SELECT SUBSTRING(geohash, 1, 5) AS grid, COUNT(*) AS cnt, AVG(lng) AS center_lng, AVG(lat) AS center_lat FROM house WHERE status = 1 AND lng BETWEEN #{minLng} AND #{maxLng} AND lat BETWEEN #{minLat} AND #{maxLat} GROUP BY gridgeohash字段在建房源时就算好存进去,避免每次查询时实时计算。SUBSTRING的长度根据 zoom 调整,zoom 越小取的前缀越短,聚合粒度越粗。
4.3 列表和地图的数据同步
列表和地图同时展示时,用户在地图上点一个聚合点,列表要滚动到对应房源;列表滚动时,地图要高亮对应标记。这个联动用「选中房源 ID」作为桥梁。
const selectedId = ref(null); // 地图点标记 function onMarkerClick(houseId) { selectedId.value = houseId; scrollToList(houseId); } // 列表项进入视口 function onListItemVisible(houseId) { if (selectedId.value !== houseId) { selectedId.value = houseId; map.panTo(houseMap[houseId]); } }注意不要双向无限触发:地图点标记触发列表滚动,列表滚动又触发地图移动,地图移动又触发列表滚动。解决办法是加一个isSyncing标志位,同步过程中忽略另一侧的事件。
5. 搜索与排序优化:从 LIKE 到全文索引的演进
5.1 关键词搜索为什么不能用 LIKE '%关键词%'
房源标题和小区名的关键词搜索,如果用LIKE '%关键词%',前置百分号会让索引失效,数据量上到十万级就明显变慢。房产系统里用户搜「某某花园」「某某路」的频率很高,必须换方案。
常见做法是用 MySQL 的全文索引(FULLTEXT),但中文分词需要配 ngram 解析器。建索引时指定WITH PARSER ngram,查询用MATCH ... AGAINST。
ALTER TABLE community ADD FULLTEXT INDEX ft_name (name) WITH PARSER ngram; SELECT id, name FROM community WHERE MATCH(name) AGAINST(#{keyword} IN BOOLEAN MODE) LIMIT 20;ngram 默认 token size 是 2,也就是按两个字切分。搜「花园」能命中「某某花园」,但搜单个字「园」不行。如果产品要求单字搜索,要把ngram_token_size设为 1,但索引会变大很多。我一般保持默认 2,引导用户至少输入两个字。
5.2 排序字段的索引设计
房源列表支持按价格、单价、发布时间排序。如果每个排序都建一个索引,写入时会很慢。实际业务里,排序几乎总是和筛选条件一起用,所以建联合索引更有效。
| 排序方式 | 推荐索引 | 说明 |
|---|---|---|
| 默认(发布时间) | idx_status_created (status, created_at) | 在售房源按时间倒序 |
| 价格升序 | idx_status_price (status, price) | 在售房源按价格升序 |
| 单价升序 | idx_status_unit_price (status, unit_price) | 在售房源按单价升序 |
| 商圈+价格 | idx_community_price (community_id, price) | 先按小区过滤再排序 |
注意联合索引里status放最前面,因为所有查询都带status = 1。如果某个排序方式查询频率很低,可以不建索引,让它在数据量小的时候走文件排序,别为了低频查询拖慢写入。
5.3 搜索结果的相关性排序
关键词搜索的结果不能只按时间排,否则搜「某某花园」时,标题里带「某某花园」的房源应该排在前面。MySQL 全文索引的MATCH会返回一个相关性分数,可以拿来排序。
SELECT h.id, h.title, c.name AS community_name, MATCH(c.name) AGAINST(#{keyword} IN BOOLEAN MODE) AS score FROM house h JOIN community c ON h.community_id = c.id WHERE h.status = 1 AND MATCH(c.name) AGAINST(#{keyword} IN BOOLEAN MODE) ORDER BY score DESC, h.created_at DESC LIMIT 20;score是全文索引算出的相关性,越高越相关。把score放排序第一位,时间放第二位,这样既保证相关性,又保证同等相关性下新发布的房源靠前。注意MATCH和AGAINST里的关键词要一致,否则 score 算不出来。
6. 避坑与排查:房源系统上线后最容易翻车的五件事
6.1 房源列表页加载慢,但数据库 CPU 不高
现象:列表页打开要三到五秒,查数据库监控发现 CPU 只有 20%,慢查询日志里也没有明显慢 SQL。
原因:大概率是 N+1 查询。列表接口返回 20 条房源,每条房源又去查一次小区信息、一次经纪人信息,总共 60 次查询。每次查询都很快,但加起来就慢了。
解决:用 JOIN 一次性把小区和经纪人信息查出来,或者在 MyBatis 里配<collection>做嵌套查询。更彻底的做法是列表接口只返回小区 ID,前端拿到列表后再批量请求小区信息,用WHERE id IN (...)一次查完。
6.2 地图找房返回的聚合点数量对不上
现象:地图上显示的聚合点数字加起来,和列表页的总数不一致。
原因:地图查询和列表查询用了不同的过滤条件。列表查询带了「在售」状态,地图查询忘了带;或者地图查询的经纬度范围比实际视野大了一圈。
解决:把地图查询和列表查询的过滤条件抽成一个公共方法,两边都调同一个方法拼条件。经纬度范围用地图 API 返回的bounds直接传,不要自己加减偏移量。
6.3 带看记录状态流转错乱
现象:用户取消了带看,但经纪人那边还显示「待确认」;或者带看已完成,用户还能点取消。
原因:状态更新没有加乐观锁,两个请求同时更新同一条记录,后写的覆盖了先写的。或者前端没根据状态隐藏按钮,用户点了不该点的操作。
解决:更新状态时加WHERE status = #{oldStatus},更新影响行数为 0 就说明状态已被改过,返回提示让用户刷新。前端根据当前状态决定显示哪些按钮,但后端必须再校验一次,不能只靠前端。
6.4 小区均价定时任务跑完后数据没更新
现象:凌晨跑了更新小区均价的定时任务,但白天看小区列表,均价还是旧的。
原因:小区列表加了缓存,定时任务更新了数据库但没清缓存。或者定时任务的事务没提交,数据根本没写进去。
解决:定时任务更新完数据库后,主动删除相关小区的缓存 key。如果小区数量多,可以用redisTemplate.delete(keys)批量删,或者给缓存设一个较短的过期时间兜底。事务问题检查@Transactional注解有没有加在 public 方法上,以及异常有没有被吞掉。
6.5 全文索引搜不到刚发布的房源
现象:新发布的房源,标题里有关键词,但搜索搜不到。
原因:MySQL 全文索引有延迟,InnoDB 的全文索引不是实时更新的,新插入的数据要等索引合并后才能搜到。或者 ngram 分词把关键词切错了。
解决:对实时性要求高的搜索,不要直接用 MySQL 全文索引,改用外部搜索引擎,或者用「数据库 LIKE + 全文索引」双查,LIKE 查最近一小时的数据,全文索引查历史数据。ngram 分词问题可以用SELECT * FROM INFORMATION_SCHEMA.INNODB_FT_INDEX_TABLE看实际切出来的词。
7. 用 Docker Compose 一键拉起整套环境
前面几章把数据模型、接口、前端筛选、搜索优化和避坑都过了一遍。最后落到一个具体技巧:怎么用 Docker Compose 把 MySQL、Redis、后端和前端一次性拉起来,方便本地复现和演示。
version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: house123 MYSQL_DATABASE: house_db command: --ngram_token_size=2 --character-set-server=utf8mb4 ports: - "3306:3306" volumes: - ./sql:/docker-entrypoint-initdb.d - mysql_data:/var/lib/mysql redis: image: redis:7-alpine ports: - "6379:6379" backend: build: ./backend depends_on: - mysql - redis environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/house_db?useUnicode=true&characterEncoding=utf8 SPRING_REDIS_HOST: redis ports: - "8080:8080" frontend: build: ./frontend depends_on: - backend ports: - "3000:3000" volumes: mysql_data:这个 compose 文件里,mysql的command加了--ngram_token_size=2,保证全文索引按两字分词。volumes把./sql挂到初始化目录,容器第一次启动时会自动执行里面的建表脚本。backend和frontend用depends_on控制启动顺序,但注意depends_on只保证容器启动顺序,不保证服务就绪,后端连数据库可能要加重试。
启动命令就一行:
docker compose up -d --build第一次跑会拉镜像、编译前后端,大概三到五分钟。起来之后访问localhost:3000就能看到房源列表页。如果要改数据库结构,改./sql里的脚本,然后docker compose down -v删掉数据卷重新来。
我自己的习惯是:每次改完数据模型,先在本地用这套 compose 跑一遍完整流程——建表、插测试数据、调列表接口、调地图接口、搜关键词——确认没问题再提交。血泪经验是,千万别在本地手动装 MySQL 和 Redis,版本差异和配置差异会让你在「明明本地能跑」和「服务器上跑不起来」之间反复横跳。Docker Compose 把环境差异抹平了,省下来的时间够你多调两个接口。
这套方案值不值得做?如果你要搭一个房源平台,或者想拿一个真实业务场景练全栈,它覆盖了房产系统 80% 的核心逻辑,剩下的 20% 是支付、合同、IM 这些可以按需扩展的模块。先把房源、小区、带看这三块跑通,后面的功能都是在这套数据模型上叠。希望帮到你。
本文还有配套的精品资源,点击获取