基于Spring Boot的房地产销售管理系统开发实践
1. 写在开发之前——这个系统到底要解决什么问题1.1 房地产销售管理管的核心不是卖房而是信息流很多同学一看到房地产销售管理系统这个题目第一反应是做一个房源展示网站放几张户型图、写几个楼盘介绍再弄个在线咨询按钮就完事了。我最初也是这么想的直到真正接触了业务需求才意识到房地产销售管理的核心其实不是展示房子而是管理从获客到成交的完整信息流。一个销售团队在日常工作中要处理的信息量是非常惊人的每个顾问手里同时跟进的客户可能有二三十个每个客户处在不同的阶段——有的刚来咨询、有的已经看过几次房、有的正在谈价格、有的已经交完定金等着签约。如果没有一个系统把这些状态统一管起来全靠Excel和微信聊天记录信息散落是必然的客户跟到一半忘了上次谈到什么价格顾问离职把客户资源整个带走这些情况在真实门店里太常见了。这套系统的价值就在这儿把房源、客户、带看记录、合同、回款这些分散的数据集中管理起来让每个角色的工作状态都透明可见。开发之前先把这些业务场景想透比急着写代码重要得多。1.2 从业务场景反推功能模块而不是从技术反推做毕业设计或者课程设计的时候有个很容易走偏的思路先想我会什么技术再想这些技术能做什么功能。正确顺序应该反过来——先把业务场景里需要做的事列出来再看用什么技术实现最合适。我梳理下来一个完整的房地产销售管理系统业务上必须覆盖这几个环节房源管理房子从哪里来、状态是什么。包括楼栋、单元、户型、面积、朝向、单价、总价以及最关键的状态字段——在售、预留、已预定、已签约、已售。这里的预留状态很容易被忽略但它对应的是真实的业务场景销售带着客户看中某套房客户需要回家跟家人商量销售会先帮客户锁房一段时间防止别的客户抢走。客户管理客户是谁、需求是什么、跟到哪一步了。这里要管的不只是客户姓名电话更重要的是意向等级和跟进记录。一个客户第一次到访可能是闲逛但看完样板间之后意向变成了高这时候他是不是需要再次跟进谁跟进的上次聊了什么这些信息都必须在系统里留痕。带看与跟进管理销售带客户去了哪套房、客户什么反应、下次约什么时间。这是销售管理系统的核心动作也是判断一个顾问工作状态的重要依据。合同与交易管理谈成了之后从认筹金到定金、首付、尾款每一步都有单据和金额。合同编号需要自动生成金额需要跟房源总价对上成交之后房源状态要自动变为已售。统计报表管理层关心的是本月成交几套、回款多少、哪个顾问业绩最好、哪个楼盘卖得慢。这个功能直接决定系统是给基层操作人员用的还是给管理层用的。这五个环节串起来就是一条完整的销售业务链。系统的功能边界也就清楚了不是做一个房源展示网站而是一个销售过程管理工具。1.3 角色设计三类人用这个系统需求各不相同需求分析阶段最容易漏掉的就是角色视角。同样一个房源列表不同角色看到的和能用到的完全不一样。我把系统角色分成三类管理员、销售顾问、客户。管理员看全量数据管所有的房源录入、人员分配、合同审核。他的核心诉求是掌控和统计。销售顾问只看自己名下或所属门店的客户和房源核心诉求是方便——快速查到自己客户的跟进状态、快速登记带看记录。这是系统使用频率最高的角色操作的便捷性直接影响他们愿不愿意用这个系统。客户客户能用的功能相对少一般是查看在售房源信息、在线预约看房、查看自己名下合同的进度。注意客户侧的登录方式和内部员工的登录方式建议分开处理客户端用手机号验证码登录员工端用账号密码。这三种角色的存在直接决定了系统必须要有权限控制而且必须在后端做好校验不能光靠前端隐藏按钮。2. 技术选型与架构设计——为什么是Spring Boot以及怎么设计数据库2.1 技术栈选型的考量Spring Boot是起点不是全部标题里点名了基于Java和Spring Boot这个选择没什么争议。Spring Boot的优势在这个项目里体现得很明显自动装配让开局配置量大幅减少内嵌Tomcat让部署变得极其简单Spring生态里现成的方案几乎覆盖了所有需求——MyBatis-Plus做持久层、Spring Security做权限、Redis做缓存、JWT做无状态认证这些都是经过大量生产环境验证的成熟组合。版本选择上我要多说一句不要一上来就追最新版。Spring Boot的版本选择有一个很现实的逻辑——你要用的中间件版本它是否兼容。比如我用的是Spring Boot 2.7.18这个版本非常关键因为它是一个长期维护的LTS版本同时对Java 8的支持非常完善。很多高校机房和实训环境还在用JDK 8如果选了Spring Boot 3.x它强制要求JDK 17你的开发环境可能直接卡死。我就见过有人选Spring Boot 3.2然后发现本地的MySQL驱动、Redis客户端都要跟着升版本别的同学用的老教程里的配置根本跑不起来最后又在踩版本兼容性的坑。建议这种规模的项目选Spring Boot 2.7.x搭配JDK 8稳妥省心。架构上我采用的是前后端分离方案后端纯RESTful API前端用Vue Element UI如果只是需要快速出效果也可以用Thymeleaf做服务端渲染看你自己擅长什么。不过前后端分离在后期扩展和分工上会更灵活也符合当前企业的技术栈习惯。核心结构如下springboot-realestate ├── src/main/java/com/project/estate │ ├── controller // 接口层 │ ├── service // 业务层 │ ├── mapper // 数据访问层MyBatis-Plus │ ├── entity // 实体类 │ ├── dto // 数据传输对象避免直接暴露实体 │ ├── config // 配置类跨域、拦截器、安全配置 │ ├── utils // 工具类JWT、时间处理、统一返回 │ └── EstateApplication.java ├── src/main/resources │ ├── mapper/xml // 复杂SQL写在这里 │ └── application.yml └── frontend // Vue项目2.2 数据库设计五张核心表关系理清一切清晰数据库设计是这个项目最重要的环节。表设计不合理后面写业务代码会处处别扭。我整理了核心表结构先看整体关系再逐个解释为什么要这么设计。规划的核心表有6张用户表sys_user、角色表sys_role、房源表house_info、客户表customer_info、带看记录表visit_record、合同表contract_info。另外需要一张用户-角色关联表sys_user_role如果是多角色权限系统还需要权限表sys_permission和角色-权限关联表sys_role_permission。房源表 house_info的关键字段字段名类型说明idbigint主键house_codevarchar房源编号如 XQ001-1-201building_namevarchar楼栋/小区名称unit_novarchar单元号room_novarchar房号house_typevarchar户型如 3室2厅areadecimal(10,2)建筑面积平方米orientationvarchar朝向南/北/东南等floorint所在楼层total_pricedecimal(12,2)总价万元unit_pricedecimal(12,2)单价元/平方米statustinyint0-在售 1-预留 2-已预定 3-已售 4-下架decorationvarchar装修情况image_urlvarchar房源图片地址remarkvarchar备注create_timedatetime创建时间update_timedatetime更新时间这个表要特别注意状态字段要设置预留预留状态。很多初学者只设在售和已售两种状态但真实业务里客户看中了、但还没交定金这个中间状态非常常见。没有预留状态销售就只能通过口头约定来留房很容易引发纠纷。客户表 customer_info的关键字段字段名类型说明idbigint主键customer_namevarchar客户姓名phonevarchar手机号id_cardvarchar身份证号intent_leveltinyint意向等级 1-低 2-中 3-高budget_rangevarchar预算范围requirementvarchar需求描述sourcevarchar客户来源网络/转介绍/门店/电话owner_idbigint归属销售顾问IDstatustinyint0-潜在 1-跟进中 2-已成交 3-已流失create_timedatetime创建时间客户表这里我要重点提醒客户资源归属字段 owner_id 必须要有。这个字段关系到销售顾问的业绩归属也是权限过滤的基础——销售顾问登录系统后只能看到 owner_id 等于自己ID的客户这是系统的基本规则。带看记录表 visit_record记录销售顾问带客户看了哪套房、客户反馈如何、下次跟进时间是什么时候。每个顾问的日常工作留痕都在这里管理人员可以通过这个表统计顾问的工作量。合同表 contract_info关联客户ID和房源ID记录合同编号、成交金额、首付金额、签约时间、审核状态。合同金额必须和房源总价关联校验这条业务规则在后面代码里要重点保障。这五张表之间的关系可以理成一条业务链销售顾问sys_user→ 认领客户customer_info→ 安排带看visit_record 关联 house_info→ 成交签约contract_info 关联 customer_info 和 house_info顺着这条链做设计代码写起来逻辑非常顺也不会出现找不到字段存某个信息这种尴尬情况。2.3 配置文件的边界和规范application.yml是项目的枢纽配置。我的习惯是把环境相关的内容全部集中在这里方便在不同环境之间切换。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/estate_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: your-secret-key-change-me expire-hours: 24几个容易踩坑的地方数据库连接串里的useUnicodetruecharacterEncodingutf8一定要加否则中文乱码跑不掉serverTimezoneAsia/Shanghai也要配MySQL 8.x 不指定时区会报错MyBatis-Plus 的逻辑删除配置可以把deleted字段管理起来避免每次都手动追加条件。3. 核心功能模块的实现——从房源到成交的业务闭环3.1 房源管理模块状态流转是设计的核心难点房源模块的业务逻辑是管理房源全生命周期。最核心的部分是房源状态的流转逻辑我把它设计成一个状态机0-在售可被客户查看可被销售选择可设置预留1-预留被某个客户锁定其他人不能查看或选择限时保留2-已预定客户已交定金房源不再公开销售3-已售签约完成房源永久下架4-下架主动下架比如样板间不出售或房源存在纠纷状态流转的操作代码如下简化版Override Transactional(rollbackFor Exception.class) public void changeHouseStatus(Long houseId, Integer targetStatus, Long operatorId) { HouseInfo house houseMapper.selectById(houseId); if (house null) { throw new BusinessException(房源不存在); } // 校验状态流转合法性已售/下架不能再被修改 if (house.getStatus() 3 || house.getStatus() 4) { throw new BusinessException(该房源已终止流转无法变更状态); } // 预留/预定时必须关联客户 if ((targetStatus 1 || targetStatus 2) operatorId null) { throw new BusinessException(预留或预定的房源必须绑定客户); } // 更新房源状态 house.setStatus(targetStatus); house.setUpdateTime(LocalDateTime.now()); houseMapper.updateById(house); }这里要强调的是Transactional注解业务操作必须满足原子性——要么成功要么回滚不能出现中间态。3.2 客户管理与跟进留痕权限过滤不能只做前端客户管理模块最常见的错误是只写增删改查把最重要的跟进记录漏掉。跟进记录的本质是留痕——销售顾问每次给客户打电话、发消息、带看都需要在系统里记一笔。没有这个功能管理者就完全无法判断一个销售顾问的工作状态。我在客户模块里加了三个接口新增客户、分页查询客户带权限过滤、新增跟进记录。查询客户的分页代码核心逻辑如下Override public PageResultCustomerVO pageCustomers(CustomerQuery query) { PageCustomerInfo page new Page(query.getPageNum(), query.getPageSize()); // 关键逻辑非管理员只能查自己名下的客户防止越权 LambdaQueryWrapperCustomerInfo wrapper new LambdaQueryWrapper(); if (!isAdmin(query.getOperatorRole())) { wrapper.eq(CustomerInfo::getOwnerId, query.getOperatorId()); } if (StringUtils.hasText(query.getKeyword())) { String keyword query.getKeyword(); wrapper.and(w - w.like(CustomerInfo::getCustomerName, keyword) .or().like(CustomerInfo::getPhone, keyword)); } IPageCustomerInfo result customerMapper.selectPage(page, wrapper); // 实体转VO并返回 return PageResult.of(result); }权限判断放在 Service 层而不是 Controller 层这是关键原则——Controller 只管参数接收和返回真正决定谁能看什么数据的规则必须下沉到业务层防止有人绕过接口直接调用。3.3 合同模块与数据一致性金额校验和防并发合同管理是这个系统中数据一致性要求最高的模块。成交金额、首付款金额、贷款金额、房源状态、客户状态这五个信息必须保持一致任何一环出错后续财务数据就是错的。为了避免并发问题我给合同创建逻辑加了两把锁数据库乐观锁和Redis 分布式锁。先看数据库层在house_info表加一个version字段每次更新房源状态时检查版本号UPDATE house_info SET status 3, version version 1 WHERE id #{houseId} AND status IN (0, 1, 2) -- 只允许从有效状态流转到已售 AND version #{oldVersion}如果影响行数为 0说明房源已经被其他人变更直接抛出房源状态已变更请刷新后重试。再看看 Redis 锁防止同一套合同被并发提交两份// 分布式锁key 房源ID防止两个销售同时录入同一套房子的合同 boolean locked redisLock.tryLock(LOCK_HOUSE_ houseId, 30, TimeUnit.SECONDS); if (!locked) { throw new BusinessException(该房源正在处理中请勿重复操作); } try { // 创建合同、更新房源状态、更新客户状态…… } finally { redisLock.unlock(LOCK_HOUSE_ houseId); }这两层保护就是实际项目里常说的乐观锁 分布式锁的组合方案能覆盖绝大多数并发场景。3.4 统计报表管理者视角的核心功能统计模块用的是 Dashboard 的形式。报表里的所有数据都必须是实时从业务表聚合出来的不能存冗余的汇总表——数据实时性对管理层很重要而且项目的并发量远没到需要预聚合的程度。后端聚合接口我提供了几个核心维度按状态统计房源数量在售多少、已预定多少、已售多少按月份统计成交合同数及成交总金额按销售顾问统计本月业绩排行按户型统计销量分布实现方式上复杂一点的直接写SQL查询比如按月统计成交数据Select(SELECT DATE_FORMAT(sign_time, %Y-%m) AS month, COUNT(*) AS contractCount, SUM(deal_amount) AS totalAmount FROM contract_info WHERE sign_time #{startTime} GROUP BY DATE_FORMAT(sign_time, %Y-%m) ORDER BY month DESC) ListMonthStatsVO countMonthlyContracts(Param(startTime) String startTime);报表数据量小SQL聚合就够了不需要引入额外的大数据组件。这个规模的项目加一堆重型技术栈纯属自找麻烦。4. 安全控制与常见隐患处理——XSS、恶意文件上传和权限绕过4.1 登录认证与权限控制JWT 比 Session 更适合前后端分离前后端分离架构下Session 方案需要额外处理跨域 Cookie 问题JWT 方案更自然。我这里的做法是登录成功后生成 JWT 返回给前端前端每次请求带上Authorization头后端拦截器校验 token把用户ID和角色塞进请求上下文。JWT 工具类的核心逻辑public String generateToken(Long userId, String username, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expireHours * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); }拦截器里需要注意几点放行登录、注册、房源展示等公开接口校验 token 时区分token过期和token无效给前端明确的响应用户重新登录设置必要的请求头信息供后续业务使用。4.2 XSS 攻击防护过滤器拦截 全局处理XSS 攻击的核心是前端没有做好输入校验把用户提交的恶意脚本当作正常数据渲染到页面里。在这个系统里主要风险点在客户姓名、备注、房源描述这些文本字段。比如一个客户备注里写了scriptalert(xss)/script如果前端直接渲染就可能执行恶意脚本。我的处理方案是加一个全局过滤器对所有请求体的内容做转义处理把特殊字符替换为安全实体public class XssFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; chain.doFilter(new XssWrapper(req), response); } } // XssWrapper 覆盖 getParameter 和 getInputStream 方法 // 把 script、iframe、javascript: 等内容替换为空或转义实体注意这个过滤器里可以自定义白名单比如富文本编辑器是允许p、strong这些标签存在的需要在转义时做放行处理。过滤器能做大部分防护但数据库层面的存储过程和安全参数化查询不能省两条防线都要有。4.3 文件上传的风险点不只配置里改个大小就完事系统里有房源照片上传、合同PDF上传、客户身份证照片上传文件上传的安全隐患最容易在细节里翻车。我在项目里做了这几个防护文件类型校验两处做前端校验扩展名是基础后端必须校验文件流的内容类型MIME和魔数不能只信扩展名。一个.jpg文件改成.html就能绕过前端校验的情况太常见了。重命名存储上传的文件直接存原文件名是非常危险的做法。我把文件名改成UUID 正确扩展名避免路径穿越和文件名注入。存储路径隔离上传文件存放目录设置为静态资源隔离区域不放在项目根目录有执行权限的目录避免上传恶意脚本后直接被服务器解析执行。明确大小限制spring.servlet.multipart.max-file-size10MB防止大文件拖垮服务。4.4 参数校验不能手写 if-else 堆起来参数校验是最容易流于形式的部分。刚开始我习惯在 Controller 手写一堆if (param null) {}写了几个接口就觉得不对劲——代码大量重复而且容易漏。后来我统一用了Validatedjavax.validation注解方案在 DTO 上定义校验规则public class CustomerDTO { NotBlank(message 客户姓名不能为空) private String customerName; NotBlank(message 手机号不能为空) Pattern(regexp ^1[3-9]\\d{9}$, message 手机号格式不正确) private String phone; NotNull(message 意向等级不能为空) Min(value 1, message 意向等级范围错误) Max(value 3, message 意向等级范围错误) private Integer intentLevel; }然后通过RestControllerAdvice统一处理MethodArgumentNotValidException把具体的校验错误返回给前端。这样每个接口的参数规则都写得很直观维护起来也比成堆的 if-else 舒服得多。5. 实际开发中踩过的坑——这些问题课本上不一定写5.1 MyBatis-Plus 字段映射的坑驼峰和下划线MyBatis-Plus 默认开启驼峰映射create_time可以自动映射到实体类的createTime。但有一个情况会翻车:如果你手写了 XML 里的复杂查询resultType 用的是 Map那查出来的字段名就全是下划线风格create_time前端拿到的 JSON 字段名不一致然后前端取不到值。解决方案是统一约定简单查询全部用 LambdaWrapper 和实体映射复杂查询专门写 VO 类做映射避免用 Map 接收结果。不要一会实体一会 Map风格混着来会把自己绕晕。5.2 分页查询里的 count 语句慢到离谱MyBatis-Plus 分页插件会自动生成 count 语句但房源列表带了很多关联条件的情况下自动生成的 count 会把所有 join 表都统计一遍数据量上来了就很拖速度。我的处理办法在复杂查询场景里手工写一条轻量级的 count SQL只统计主表的行数配合分页查询使用select idcountPage resultTypelong SELECT COUNT(*) FROM house_info h where if teststatus ! null AND h.status #{status}/if if testkeyword ! null AND (h.building_name LIKE CONCAT(%, #{keyword}, %) OR h.house_code LIKE CONCAT(%, #{keyword}, %) )/if /where /select5.3 日期时间处理上的时区黑魔法这个坑我印象很深。我第一次部署完系统测试的时候发现新增的房源创建时间比本地时间快了8个小时数据库里存的时间完全不对。原因很典型MySQL 连接串没有配置serverTimezone默认取的是服务器系统时区而本地机器的时区是东八区两边一错位就是8小时的偏差。更隐蔽的是前端处理时间的方式。后端返回2024-01-15T10:30:00这种带T的 ISO 字符串如果前端 JS 直接new Date()展示的时候会发现时间又差了一截。我没有用复杂的全局序列化方案而是统一在 VO 里用字符串输出格式化好的时间后端承担时间格式化前端拿来直接显示反而少了很多问题JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createTime;5.4 数据一致性不只靠代码数据库的外键约束也不能省有一个常见的观念是外键会影响性能所以不用。在低并发管理系统的场景下这个担心是多余的。我见过同事做的项目房源、客户、合同三张表之间没有任何数据库约束全靠 Java 代码自己保证关联关系。结果就是合同表里能出现不存在的房源ID客户删除的时候带看记录变成孤数据。在这个项目里我在关键业务表之间建立了外键约束合同表 → 房源表/客户表、带看记录表 → 客户表并把删除策略设为RESTRICT——有关联数据时不允许直接删除。这个决定让数据完整性在数据库层面就托了底代码里偶尔漏校验也不会出大事。5.5 项目打包部署的几个注意点打包部署是整个流程的最后一步但很多人在这一步才暴露出问题。我用的是 Maven 打包成可执行 jar部署前确认这几个点application.yml里的数据库地址、Redis 地址改成生产环境地址连接密码不要用弱口令打包前先跑一遍测试避免把测试环境的配置打进包里jar 包的启动方式建议写成脚本nohup java -jar estate.jar --spring.profiles.activeprod 方便后台运行服务器磁盘上上传目录要单独设置不要放在/tmp或者项目解压目录下防止清理临时文件时误删另外我还有个习惯项目里放一个docs文件夹把数据库初始化 SQL建表语句初始数据和接口文档说明放在一起。打包或者交接的时候别人照着文档能快速把环境跑起来这个习惯在团队协作和毕设答辩时都很加分。我个人对这个项目最深的体会是管理系统这类项目的难点永远不在某一个技术点有多难而在业务逻辑的完整性和严谨性。房源状态怎么流转、客户归属怎么隔离、合同金额怎么校验、并发操作怎么防重——这些才是真正花时间的地方。把业务链摸透了Spring Boot 充其量只是把已经想清楚的设计翻译成代码而已。如果你正在做类似的系统建议先花时间把需求场景、表结构和状态流转图画清楚代码只是顺水推舟的事。