
每年到这个时间点总能看到一大批计算机专业的同学在选题上纠结尤其是“城市房产信息网”“不动产综合服务平台”这类题目几乎是毕业设计里的常青树。但要提醒一句这类系统看着简单真做起来十个里面有八个做成“玩具”——能注册、能登录、能发个静态房源列表就算交差。这显然是不够的。我今年正好完整地做了一套基于SpringBoot的城区不动产综合服务平台涵盖了房产交易、租赁管理、经纪人入驻、预约看房、后台审核等完整闭环。这篇文章就把整个项目的选型逻辑、数据库设计、核心接口实现、安全加固和部署调优全部分享出来特别是那些常规教程里不会讲的坑。想选这个题目的同学可以直接把这套思路当作蓝本去扩展比从零开始瞎想要省事得多。1. 项目整体设计与技术选型思路1.1 为什么这个题目值得做以及要避开哪些坑城市房地产信息网的本质是一个“房源信息中介平台”它和普通CRUD系统的最大区别在于业务角色多、状态流转复杂、房源信息字段繁多并且对安全性的要求高于一般的管理系统。对比一些常见的选题比如“图书管理系统”“学生选课系统”这类题目的业务逻辑单薄表结构基本两三天就能设计完答辩时很难展开讲。而房产信息平台天然具备至少五种角色游客、普通用户、房东/经纪人、管理员、四种核心业务状态在售、已售、出租、下架、三类核心操作流发布、审核、交易/租赁。这些业务节点足够支撑你在论文里画出有说服力的流程图和数据流图也让评委有东西可问、你有东西可答。但这道题也有明显的坑很多同学一上来就堆功能什么地图找房、VR看房、在线签约、电子合同全都想做。结果就是代码写了一堆每一个功能都没做完答辩时连一条完整业务链路都跑不通。我的建议是核心链路永远优先。所谓核心链路就是“发布房源 → 后台审核 → 前台展示 → 用户收藏/预约 → 线下成交 → 房源状态变更”。只要这条链路是通的、数据是一致的这个项目的骨架就站住了。其他功能全是锦上添花。1.2 技术栈选型背后的理由我的技术选型如下后端框架Spring Boot 2.7.x权限方案Spring Security JWT持久层MyBatis-Plus数据库MySQL 8.0缓存Redis前端Vue 2 Element UI后台管理端、Vue 2 Vant用户移动端为什么选Spring Boot而不是Spring MVC的传统SSM原因很直接。Spring Boot的自动配置能把开发环境搭建成本压到最低起步依赖解决依赖冲突问题内嵌Tomcat让打包部署一键完成。对于毕业设计这种有时间节点的项目省下来的环境配置时间应该拿去打磨业务逻辑。为什么选MyBatis-Plus而不是JPA我个人的理由是房产系统的查询场景非常复杂按区域筛选、按价格区间筛选、按面积筛选、多个条件组合排序SQL的可控性非常重要。MyBatis-Plus的Wrapper构造器能覆盖90%的单表查询场景复杂统计再写XML里的自定义SQL两全其美。JPA虽然开发效率也不错但一旦查询复杂起来方法命名会非常冗长动态条件拼接也没有Wrapper那么直观。前端选Vue 2没选Vue 3不是Vue 3不好而是Element UI和Vant的生态成熟、坑少、教程多。毕业设计的时间本身就紧与其花一周时间踩Vue 3 Element Plus的兼容问题不如用Vue 2把核心功能全部跑通。如果你对Vue 3已经非常熟练用Vue 3 Element Plus完全没问题原理相同。1.3 模块划分与项目目录结构系统拆成三个端前台门户、用户中心、后台管理。前台门户面向游客和普通用户负责房源展示和检索用户中心面向登录用户、房东和经纪人管理个人房源、预约和收藏后台管理面向平台运营人员处理房源审核和用户管理。对应的后端包结构长这样com.example.estate ├── common // 通用响应、异常处理、工具类 ├── config // 配置类Security、Redis、CORS ├── controller // 控制层 ├── service // 业务逻辑层 ├── mapper // MyBatis-Plus持久层接口 ├── entity // 数据库实体 ├── dto // 数据传输对象 ├── vo // 视图对象用于返回给前端的封装体 ├── utils // JWT工具、分页工具、文件上传工具 └── aspect // 切面操作日志我特别想强调vo包的重要性。很多新手喜欢直接用entity返回给前端这在毕设阶段可能看不出问题但一旦遇到“房源实体里有userId而前端需要的是发布人的昵称”你就得在entity里加冗余字段结构就乱了。建议所有接口一律返回vo对象前端需要什么字段vo里就有什么字段后端响应结构保持稳定。2. 数据库设计与核心表结构详解2.1 数据库设计的基本盘房产系统涉及的数据表大致有用户表、房源表、房源图片表、区域表、收藏表、预约看房表、审核记录表、浏览记录表、公告表。表不要太多控制在10到12张左右就足够了。不要为了设计而设计每一张表都要能说清楚为什么存在。核心表的设计直接决定业务能否走得通我逐个说一下。2.2 用户表与房源表字段定义的关键细节用户表的基本字段大家都懂关键是几个特殊的点role字段用int0表示普通用户1表示房东/经纪人2表示管理员。不要用字符串存角色名排序和判断都不方便。status字段做禁言/封禁标记被封禁的用户不能登录也不能操作房源。phone字段必须做唯一索引登录和密码找回都依赖手机号。下面是简化的建表语句真实项目中还会加create_time、update_time这类基础字段。CREATE TABLE t_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, avatar varchar(255) DEFAULT NULL, role tinyint DEFAULT 0 COMMENT 0普通用户 1房东 2管理员, status tinyint DEFAULT 1 COMMENT 1正常 0封禁, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY idx_username (username), UNIQUE KEY idx_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;房源表是整个系统里字段最多的表也是最容易设计出问题的表。最高频的坑有三个第一价格字段用DECIMAL而不是FLOAT。FLOAT在MySQL中是非精确类型做范围查询时会出现边界值判断不准的问题。第二面积字段建议保留两位小数和测绘数据的精度对齐。第三房源编号要单独用一列存一个业务编号格式比如RJ20250101001不要直接用自增主键暴露给前端主键一旦被遍历平台的房源数据就全被爬走了。房源表的简化设计如下CREATE TABLE t_house ( id bigint NOT NULL AUTO_INCREMENT, house_code varchar(32) NOT NULL COMMENT 业务编号, title varchar(100) NOT NULL COMMENT 房源标题, cover_img varchar(255) DEFAULT NULL COMMENT 封面图, price decimal(12,2) NOT NULL COMMENT 价格元/月或元/平, area decimal(8,2) DEFAULT NULL COMMENT 面积平方米, house_type tinyint DEFAULT 0 COMMENT 1整租 2合租 3二手房 4新房, room_count tinyint DEFAULT NULL COMMENT 几室, hall_count tinyint DEFAULT NULL COMMENT 几厅, orientation varchar(10) DEFAULT NULL COMMENT 朝向, floor varchar(20) DEFAULT NULL COMMENT 楼层, decoration varchar(20) DEFAULT NULL COMMENT 装修情况, region_id int DEFAULT NULL COMMENT 所属区域ID, address varchar(255) DEFAULT NULL COMMENT 详细地址, description text COMMENT 房源描述, publisher_id bigint NOT NULL COMMENT 发布者ID, status tinyint DEFAULT 0 COMMENT 0待审核 1在售/在租 2已成交 3已下架 4审核驳回, audit_remark varchar(255) DEFAULT NULL COMMENT 审核不通过原因, view_count int DEFAULT 0 COMMENT 浏览次数, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_region_price (region_id, price), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT房源表;注意idx_region_price这个联合索引它对应了前台最核心的检索场景“按区域价格排序”。如果你的查询经常是“某个区域里价格升序”这个索引就能派上大用场。如果不需要按区域检索这个索引删掉也行但实际项目中它几乎必然存在。2.3 房源图片表与区域表一套可复用的数据结构房源图片用一个独立表存多张图片而不是在房源表里拼一个JSON字符串这样方便前端做缩略图加载也方便做图片懒加载。表结构很简单就是id、house_id、img_url、sort_order四列。区域表是容易被忽略但很有价值的表。很多同学把“区域”直接做成房源表里的字符串字段比如“朝阳区”“海淀区”然后检索用LIKE匹配。这么做的问题非常明显一旦你要做“区域联动筛选”或“区域房源数量统计”字符串匹配的效率和扩展性都很差。我建议独立建一张t_region表字段为id、name、parent_id如果你愿意做两级联动用parent_id存父级区域的编号就行。区域表随着后台可维护。2.4 搭建本地演示数据光有表结构没有数据跑起来界面空荡荡的感觉也差很多。建议写一个data.sql往房源表里塞20到30条模拟房源覆盖不同的区域、价格段、房型和状态。这里有个小技巧用MySQL的递归CTE生成批量数据省时省力。INSERT INTO t_house (house_code, title, cover_img, price, area, house_type, room_count, hall_count, orientation, floor, decoration, region_id, address, description, publisher_id, status, create_time) WITH RECURSIVE seq AS ( SELECT 1 AS n UNION ALL SELECT n 1 FROM seq WHERE n 30 ) SELECT CONCAT(RJ2025, LPAD(n, 3, 0)), CONCAT(x小区, n, 号房源), CONCAT(/img/house/, n % 8 1, .jpg), 2000 (n * 137) % 8000, 50 (n * 7) % 80, IF(n % 3 0, 1, 2), 1 n % 3, 1, ELT(1 n % 4, 东, 南, 西, 北), CONCAT(n % 25 1, /, 25), ELT(1 n % 3, 精装, 简装, 毛坯), 1 n % 8, CONCAT(幸福路, n, 号), 这是一套测试房源适合演示系统功能。, 1 n % 5, 1, NOW() FROM seq;生成之后再配合业务手动刷几条已成交和待审核状态的数据这样三个核心状态在前后台都能看到效果演示时不会出现“前台一片空白”的尴尬。3. 核心功能接口的设计与实现3.1 房源检索接口条件组合与分页的细节搜索是房产系统的门面用户进来第一件事就是找房。搜索接口要支持的关键词包括区域、价格区间、面积区间、房型、朝向、出租类型。这些条件不是每次全传接口必须做动态拼接。用MyBatis-Plus的LambdaQueryWrapper实现如下Override public PageResultHouseVO searchHouse(SearchDTO dto) { PageHouse page new Page(dto.getPageNum(), dto.getPageSize()); LambdaQueryWrapperHouse wrapper new LambdaQueryWrapper(); // 状态必须等于在售/在租未审核和已下架的房源一律不展示 wrapper.eq(House::getStatus, 1); if (StringUtils.hasText(dto.getKeyword())) { wrapper.and(w - w.like(House::getTitle, dto.getKeyword()) .or().like(House::getAddress, dto.getKeyword())); } if (dto.getRegionId() ! null) { wrapper.eq(House::getRegionId, dto.getRegionId()); } if (dto.getMinPrice() ! null) { wrapper.ge(House::getPrice, dto.getMinPrice()); } if (dto.getMaxPrice() ! null) { wrapper.le(House::getPrice, dto.getMaxPrice()); } if (dto.getHouseType() ! null) { wrapper.eq(House::getHouseType, dto.getHouseType()); } // 排序最新发布优先 wrapper.orderByDesc(House::getCreateTime); PageHouse result houseMapper.selectPage(page, wrapper); // 组装返回VO ListHouseVO voList result.getRecords().stream() .map(house - convertToVO(house)) .collect(Collectors.toList()); return new PageResult(voList, result.getTotal()); }分页细节容易被忽略我提两点。第一Page对象传入的页码从1开始前端分页组件通常也从1开始这个没问题。但如果有人传了超大页码比如pageNum9999MySQL一样会去计算偏移量然后返回空数据不会报错但接口响应会慢。建议在入口处对pageNum做一次上限校验比如最大100页超出就拉最后一段。第二分页查询在数据量大时一定要确认SQL里带了LIMIT ? OFFSET ?MyBatis-Plus的selectPage默认会做但如果你在XML里自己手写了select * from t_house那就不会自动分页了。3.2 房源详情页浏览量计数与Redis缓存策略详情页是高频访问页面也是最容易被打爆的页面。浏览量的实现如果每次请求都执行一次UPDATE t_house SET view_countview_count1数据库压力会非常明显。在实际项目中我用了Redis来缓冲浏览量先更新缓存中的计数定期批量刷回数据库。这个方案的实现思路是用户浏览房源时先查Redis里的house:view:{id}有则加1无则初始化并加1另外Redis中保存一个标记当累计到一定次数或者到了定时任务触发时间再把增量同步到数据库。如果毕设阶段不想引入定时任务简化方案是每10次增量写一次库或者干脆每次访问都加但给view_count加上索引压力也不大。毕竟毕设没有高并发关键在“逻辑自洽”答辩时被问到如何优化你能说得清楚就是加分项。详情页本身建议加一级缓存因为房源数据不是高频变化的。我用的方案是Cacheable注解配合Redis以house:detail:{id}为key缓存10分钟。等后台审核或房源状态变更时手动CacheEvict掉对应key。这里有个容易翻车的点如果缓存了House实体而实体里包含description这种大字段那Redis里存的就是一串很长的JSON。如果房源图片还在实体里做了级联查询内存和带宽都会被浪费。所以缓存的对象请用轻量级的HouseVO只保留详情页需要的核心字段。3.3 发布与审核流程状态机驱动的业务闭环用户在发布房源时系统默认把status设为0待审核然后管理员在后台审核。审核通过置为1驳回置为4并填写audit_remark。这个流程看起来简单但容易忽略一个点用户发布成功之后能不能再次编辑已发布的房源答案应该是不能直接编辑必须重新提交审核否则就会出现“审核通过的内容被偷偷改成违规内容”的问题。所以update接口也要带状态判断只允许编辑状态为“待审核”或“审核驳回”的房源。管理员审核核心代码Transactional public void auditHouse(Long id, Integer status, String remark) { House house houseMapper.selectById(id); if (house null) { throw new BizException(房源不存在); } // 只允许对待审核状态的房源做审核操作 if (house.getStatus() ! 0) { throw new BizException(该房源状态不允许审核); } House update new House(); update.setId(id); update.setStatus(status); update.setAuditRemark(remark); houseMapper.updateById(update); // 写一条审核日志 AuditLog log new AuditLog(); log.setHouseId(id); log.setOperatorId(CurrentUser.getId()); log.setAction(status 1 ? 通过 : 驳回); log.setRemark(remark); auditLogMapper.insert(log); }事务一定要加上不然审核状态更新了而日志没写进去数据就不一致了。而且注意这个操作只能用于状态为“待审核”的房源防止重复审核。3.4 收藏与预约看房一对多关系与防重处理收藏表和预约表是多对一关系一个用户对应多条记录一个房源对应多条记录。收藏的防重逻辑很简单唯一索引(user_id, house_id)加上插入时捕获重复键异常即可。预约看房的防重就要复杂一些。同一套房源同一个人一天内只能预约一次同一时段内预约人数要限制避免看房时间撞车。我用了一张预约表和一组约束来实现(house_id, user_id, booking_date)做唯一索引然后业务层校验当天是否已有预约。同时在t_booking表里加一个status字段0表示待确认1表示已确认2表示已取消。这里有一个在毕设答辩时经常被问到的点用户在前台发起了预约这个数据怎么流转到经纪人我的做法是后台管理端有一个“预约管理”菜单经纪人登录后台后能看到属于自己的房源的预约列表确认后状态变为已确认前端用户能在“我的预约”中看到进展。这构成了一个完整的双向信息流。4. 安全方案与权限控制实现4.1 登录认证JWT 与 Spring Security 的组合方式这套系统里用户、房东、管理员共用一张用户表登录后根据role字段区分权限。JWT的流程是登录成功后签发一个Token包含userId和role前端把Token存在localStorage里每次请求带上Authorization: Bearer token。后端用Spring Security的过滤器链在UsernamePasswordAuthenticationFilter之后加一个JWT过滤器解析Token并设置SecurityContext。没有Token或Token无效时对于需要登录的接口直接返回401。关键点在于哪些接口需要登录。首页的房源列表、房源详情这些是公开的发布房源、预约看房、收藏是被保护的后台所有接口都必须是管理员权限。Spring Security的配置可以在configure(HttpSecurity)里做http.authorizeRequests() .antMatchers(/api/auth/**, /api/house/list, /api/house/detail/**).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() .and() .csrf().disable();需要额外说明的是Security的hasRole(ADMIN)会自动给角色加上ROLE_前缀存到GrantedAuthority所以你在JWT过滤器中构建权限时要把用户角色拼成ROLE_ADMIN的形式不然hasRole永远匹配不上。这个坑我踩过排查了一个小时才发现是大小写和前缀的问题。4.2 密码加密与敏感数据处理密码一律用BCrypt加密不要用MD5更不要明文存储。Spring Security自带的BCryptPasswordEncoder就够用。注册时加密登录时matches比对。MD5的问题不仅仅是碰撞风险更关键的是可以批量查彩虹表BCrypt自带随机盐同样的密码每次生成的密文都不一样安全性不是一个量级。用户手机号在列表中默认脱敏显示比如138****8888只有管理员在后端能看到完整号码。这个在返回VO时直接处理不要依赖前端脱敏不然接口被抓包后就泄露了。4.3 文件上传安全与访问控制房源图片上传我用的方案是本地磁盘存储Nginx映射静态路径访问。有两个细节要注意。第一个是上传校验。文件后缀白名单限定jpg/png/webp大小限制在5MB以内同时校验Content-Type。不要用getOriginalFilename()直接判断后缀攻击者可以改后缀绕过比如上传一个shell.jsp.jpg。第二是文件名不能沿用原始文件名必须用UUID重命名。预留原始文件名的会导致路径穿越漏洞也可能造成图片覆盖。图片的访问路径可以直接通过Nginx映射到本地目录避免走后端接口读图片字节流既省资源又简单。5. 前后端联调与性能优化实践5.1 接口响应体与跨域处理统一才是硬道理前后端要约定一个统一的返回结构。我用的结构如下{ code: 200, message: success, data: { } }后端封装一个ResultT对象所有Controller都返回它。这样前端的axios拦截器只看code是不是200是200就解data否则统一弹错误提示。一定不要有的接口返回Result有的接口直接返回List或Map那前后端联调的时候你会被自己气死。跨域配置我用CorsFilter解决。本地开发时前端跑在localhost:8080前端端口后端跑在8081如果不配置CORS浏览器直接拦截请求。配置时要指定允许的请求头里有Authorization否则JWT带不过去。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:8080); config.addAllowedHeader(*); config.addAllowedMethod(*); config.addExposedHeader(Authorization); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }5.2 接口慢查询分析与优化手段系统做完之后我发现两个接口响应慢一个是后台房源列表一个是区域房源统计。排查手段是看MySQL的慢查询日志或者直接在本地开EXPLAIN。后台房源列表慢的原因通常是没有把管理员筛选条件和分页逻辑组合好OR条件导致索引失效。我把查询拆成了两条先按精确条件过滤再对结果做模糊搜索或者在模糊搜索字段上建全文索引。对于毕设规模的数据量最简单的解决办法是给status和publisher_id建联合索引让管理员筛选不走全表扫描。区域房源统计慢如果用的是GROUP BY region_id数据量不大的时候完全没问题。但如果你想做得更好可以在区域表里冗余一个house_count字段发布、上架、下架时异步更新。这是典型的“空间换时间”思路答辩时讲出来会显得你有性能意识。5.3 Docker Compose 一键部署部署环节我是推荐Docker Compose的。写一个docker-compose.yml把MySQL、Redis、后端jar包和前端静态文件一股脑编排起来。这样无论是在本地还是云服务器都能做到“一键起服务”。一个精简的编排文件长这样version: 3.8 services: mysql: image: mysql:8.0 container_name: estate-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: estate_db ports: - 3306:3306 volumes: - ./mysql-data:/var/lib/mysql restart: always redis: image: redis:6.2 container_name: estate-redis ports: - 6379:6379 restart: always backend: build: ./backend container_name: estate-backend depends_on: - mysql - redis ports: - 8081:8081 environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/estate_db?useUnicodetruecharacterEncodingutf8 SPRING_REDIS_HOST: redis restart: always frontend: build: ./frontend container_name: estate-frontend depends_on: - backend ports: - 8080:80 restart: always需要注意Docker容器间的通信要用服务名mysql、redis而不是localhost因为两个容器各自的localhost并不互通。后端配置文件里的数据库地址必须是mysql、Redis地址必须是redis。6. 常见问题排查与实操避坑实录6.1 高频Bug清单这些坑我几乎每次都踩下面这些问题是我自己做这类系统时反复遇到的整理成速查表节省你排查时间。症状可能原因排查与解决房源列表接口500MyBatis-Plus实体映射了不在表中的字段检查TableField(exist false)注解保存房源时中文乱码JDBC连接没有加characterEncodingutf8URL加上useUnicodetruecharacterEncodingutf8懒加载序列化报错实体中关联对象没在事务中访问转VO时在Service层完成关联查询避免Controller层查库日期返回格式不对如2025/01/01Jackson默认序列化日期不是yyyy-MM-dd格式在application.yml里配置spring.jackson.date-format上传图片404图片存到本地磁盘但没映射静态目录配置WebMvc的ResourceHandler映射到上传目录登录后访问接口总是401Token过期或Security配置拦截了公开接口检查antMatchers的路径是否覆盖了所有公开接口前端请求跨域报错没加CORS配置或配置的Origin不含前端地址用CorsFilter全局配置明确放行Authorization请求头Redis缓存了旧数据没有在写操作时主动清缓存审核、编辑、删除房源时调CacheEvict6.2 预约并发“超售”问题与解决方案如果多个用户同时预约同一套房源就可能出现一个时间段被重复预约的情况。解决方案是乐观锁或数据库唯一约束。最简单有效的方法是在t_booking表上建联合唯一索引数据库层面直接拒绝重复预约ALTER TABLE t_booking ADD UNIQUE KEY uk_house_user_date (house_id, user_id, booking_date);然后在Service捕获DuplicateKeyException转换成友好的业务提示“您已预约过这套房源”。这个方法不用分布式锁、不用Redis事务适合毕设阶段的并发量同时回答“如何防止重复提交”也是标准答案。6.3 答辩前要注意的几个细节最后再说几点答辩经验。第一系统里一定要有真实的种子数据。有些同学数据库里只有一两套房源演示的时候点来点去全是同一条记录观感很差。至少准备20条房源数据覆盖不同状态。第二数据库设计的文档要提前画好ER图答辩时评委几乎必问表关系。第三自己提前想清楚一个业务场景从头到尾的数据流转比如用户从注册、登录、发布房源、管理员审核、展示到预约看房的完整路径讲得流畅就成功了一大半。我个人的感觉是这个题目的上限可以做到很高下限也真的可以做到很低。关键在于你是否愿意在核心业务链路上花心思把它打通、做扎实。把这些基础功能真正做到位你就已经比大部分选这个题的同学都强了。