SpringBoot+Vue+MyBatis+MySQL实战:房屋租赁管理系统设计与部署
做房屋租赁管理系统这套代码时我脑子里一直有一个画面一个手里有十几套房源的房东还在用Excel登记租客信息和收租日期合同到期全靠翻日历水电费一个月一算账就头大。于是我决定用SpringBoot Vue MyBatis MySQL这套组合从零搭一套能跑起来的系统把房源、租客、合同、账单、统计这些环节全部串起来。这篇文章不是讲PPT式的架构图而是完整记录这套源码背后的设计思路、核心实现、部署过程以及我踩过的一些坑适合正在做毕设、准备转行的初级开发者或者想快速落地一套管理后台的小团队参考。1. 项目到底做了什么整体定位与需求拆解1.1 谁在用这套系统解决什么问题房屋租赁管理系统的服务对象其实很明确主要是三类人房东、租客、还有中间做托管的中介或个人运营者。很多小规模租赁业务根本用不到市面上那种大型物业管理软件功能太重、权限太复杂一套简单干净的轻量系统反而最实用。这套系统解决的核心痛点有四个。第一是房源信息混乱一栋楼里哪些房间在租、哪些空置、哪些即将到期靠脑子根本记不住第二是租客资料分散身份证号、合同、押金收据散落在不同表格里退租时翻半天第三是租金和水电费计算繁琐特别是按天结算、中途退房的时候手动算经常算错第四是缺乏统计视角一个月到底收了多少租金、空置率多高完全没有数据支撑。所以系统功能设计紧紧围绕这些痛点展开房源管理负责维护房间基础信息和状态租客管理负责信息登记和档案查询合同管理处理签约、续租、退租账单管理自动生成租金和水电费并记录实收金额数据看板提供月度租金、入住率、应收实收对比。这五个模块加上用户登录和权限控制就构成了一套完整可用的业务闭环。1.2 为什么偏偏是SpringBoot Vue MyBatis MySQL这个组合技术选型这个问题每个项目开工前都要被问一遍。这套组合不是最炫的但绝对是最稳、最不折腾的组合之一。SpringBoot的核心价值在于“少配置”。传统SSM项目光spring、springmvc、mybatis的xml配置就要写一大堆SpringBoot通过自动配置把这些活儿都干了。开发阶段内嵌Tomcat不用再单独装服务器双击启动类就能跑这对单人开发和中小型项目来说是巨大的效率提升。Vue这边的优势是组件化和数据驱动。像房源列表、合同列表、账单列表这些页面的结构高度相似封装成组件之后复用性极强。页面上的状态变化用数据绑定自动更新不再需要像jQuery那样手动操作DOM。Vue的学习曲线在主流前端框架里也是最低的一个人同时维护前后端完全吃得消。MyBatis的选择考虑就更实际了。租赁业务的报表查询和账单汇总往往带有复杂的多表关联和条件判断MyBatis允许直接用SQL控制查询逻辑动态SQL在开发调试时很直观——出问题能直接把SQL拿出来在Navicat里跑。相比JPA全自动的实体关联MyBatis的“半自动”反而让开发者对SQL有绝对掌控这对业务统计查询非常关键。MySQL就不用多说了开源免费、资料多、运维简单租个小服务器就能跑。这个组合的分工很清晰MySQL管数据存储MyBatis管数据库操作SpringBoot管后端服务和接口Vue管用户界面每一层都能找到海量资料遇到问题几乎都能搜到解决方案对独立开发者非常友好。2. 核心功能模块与数据库设计2.1 业务模块都有哪些各自管什么这套系统的业务模块可以拆成六个大块每一个都有明确的边界。房源管理是基础模块负责楼栋、单元、房间号、户型、面积、朝向、装修情况、月租金、押金规则、房源状态的数据维护。房源状态一般用枚举管理空闲、已租、维护中、已下架。每次合同状态变化房源状态必须同步联动。租客管理是客户档案模块记录姓名、手机号、身份证号、紧急联系人、职业信息、入住时间。这里面身份证号属于敏感数据数据库里可以直接存但接口返回时要脱敏处理前端列表只显示中间几位带星号的版本。合同管理是整个系统的业务核心。一份租赁合同包含合同编号、关联房源、关联租客、起止日期、月租金、押金、付款方式月付/季付/年付、物业费由谁承担等字段。系统要支持新建、续签、退租三个核心动作。新建合同时要校验当前房源状态必须是空闲退租时要联动结算未交账单和押金退还。账单管理负责租金和水电费的生成与收款记录。租金账单通常按合同周期批量生成水电费根据抄表数按月录入。账单状态从未缴、部分缴纳、已缴清到已退款每个状态变化都记录时间戳。运营统计模块用图表展示整体经营状况包括月度应收租金、实收金额、房源空置率、即将到期合同列表。统计页面的数据一般通过聚合SQL实现这个模块是体现MyBatis灵活性的最佳场所因为统计SQL往往会带各种条件动态拼接。最后一个模块是系统管理包含用户登录、权限角色、操作日志。小项目可以只分管理员和普通员工两种角色用Spring Security或者拦截器做简单鉴权都行。2.2 MySQL表结构设计的关键细节数据库设计决定了项目做到后面会不会返工这块值得花时间打磨。核心表我建议至少设计八张用户表、房源表、租客表、合同表、账单表、抄表记录表、操作日志表外加一个系统配置表。房源表里的字段看起来简单但有几个容易忽略的细节。房间编号建议用“楼栋-单元-房间号”的组合比如3-2-501同时单独存楼栋、单元、房间号三个字段这样后面按楼栋统计的时候直接分组就行不用对字符串做拆分。月租金用DECIMAL(10,2)状态用TINYINT删除标记用逻辑删除字段deleted0正常1已删除加上唯一索引约束房间号的唯一性。合同表的设计要特别注意时间字段。起止日期用DATE类型created_at和updated_at用DATETIME这两个时间字段在MyBatis-plus或原生MyBatis里配合自动填充都很方便。押金字段用DECIMAL(10,2)合同状态用TINYINT合同编号用业务规则生成比如“HT202501001”方便后期人工核对。账单表是整个系统里数据量增长最快的表一个月一租就能产生不少记录。账单号、合同编号、账期月份、租金金额、水费、电费、合计金额、实收金额、账单状态、缴费时间这些字段一个都不能少。特别要注意的是账期用字符串“2025-01”还是用日期类型我建议用日期类型的月份第一天方便用BETWEEN查询和按月分组统计。抄表记录表记录每个账单期的水电表底数上期底数、本期底数、倍率、本期用量、单价、金额。这里有个实际业务点水费和电费的单价可能随时调整所以每一条抄表记录里必须冗余存储单价否则历史账单会随着调价被算错后面对账就是一笔糊涂账。索引设计上合同表的house_id、tenant_id、status都要建索引因为查询频率极高账单表的contract_id、period、pay_status建联合索引支撑“某个合同某个月份账单是否存在”的快速判断同时防止重复生成账单。这个唯一性很重要我在后面章节会专门说幂等控制的问题。2.3 设计表时最容易踩的坑第一个坑是MySQL的关键字。给表起名千万别用order、describe、group这些保留字需要时加反引号也能跑但每次写SQL都要背反引号非常折磨人。建议一开始就用house、contract、bill这种没歧义的命名。第二个坑是外键约束。我在早期项目里特别喜欢建外键后来发现业务复杂了、数据量上来了外键在删除和更新时会带来一堆麻烦。租赁系统的推荐做法是逻辑关联也就是只在业务层维护关联关系数据库里建普通索引不建物理外键。这样万一需要调整数据或做历史数据迁移不会因为外键约束卡住。第三个坑是字段类型选择。金额一定用DECIMAL不要用FLOAT或DOUBLE浮点数的精度问题在累计多笔账单后会放大对账时差个几毛钱非常尴尬。手机号、身份证号用VARCHAR存不要用INT身份证号在Java里用Long会溢出用String最稳妥。布尔字段用TINYINT(1)或CHAR(1)都行但要么全项目统一、要么在文档里写清楚别混着用。第四个坑是逻辑删除和唯一索引的冲突。如果房源表加了逻辑删除字段同时又要保证房间号唯一就会遇到一个问题——删除一条房源后再添加同房间号的房源唯一索引会冲突。解决方案有两种唯一索引改成“房间号 deleted”的联合唯一索引或者删除时把房间号做后缀处理我建议用前者。3. 后端SpringBoot MyBatis 实操拆解3.1 工程结构与分层这套系统的后端工程我推荐按经典的分层结构组织Controller层只做参数接收和响应封装Service层负责业务逻辑和事务控制Mapper层只做数据库操作Entity对应数据库表结构DTO做前后端数据传输VO做视图对象、控制返回给前端的数据格式。Controller里有一个细节要注意分页参数pageNum、pageSize、关键字keyword这些建议封装成一个统一的查询对象避免每个接口都写一堆孤零零的RequestParam。响应数据统一用Result类包装包含code、message、data三个字段前后端约定好code为200表示成功其它都是异常。这样前端Axios拦截器里只需要判断一次code整体的错误处理逻辑会清爽很多。Service层是业务逻辑集中地比如创建合同的流程是校验房源状态是空闲、校验租客是否存在、检查同一个时间段内该房源没有其它有效合同、创建合同记录、把房源状态改为已租、生成首期账单。每一步失败都要抛出明确的业务异常不要让流程半途中断。这类操作必须加上Transactional(rollbackFor Exception.class)保证任何一步出错整体回滚。3.2 MyBatis关键配置与XML写法application.yml里关于MyBatis的配置不多但这几个必须要写对。mapper-locations指定XML文件的位置type-aliases-package指定实体类包路径map-underscore-to-camel-case设为true之后数据库字段house_no才能自动映射为Java属性houseNo否则只能写一堆ResultMap或者用别名硬顶。依赖方面需要特别注意版本兼容。如果用的是SpringBoot 2.x引入mybatis-spring-boot-starter 2.x系列如果用的是SpringBoot 3.x就必须引入3.x版本的starter并且JDK要求17以上。我之前遇到过一个诡异的报错Tomcat启动失败查了半天才发现是JDK版本和SpringBoot版本不匹配。MySQL驱动这块8.0版本的驱动类的driverClassName是com.mysql.cj.jdbc.Driver如果沿用老的com.mysql.jdbc.Driver会直接启动失败。XML文件里的动态SQL是MyBatis最实用的能力。以房源列表查询为例搜索条件可能包含状态、楼栋、租金范围、房间号模糊查询。用 标签加 条件判断可以让SQL在条件为空时自动省略对应语句块不用手拼字符串。批量插入是另一个高频场景批量生成账单时用 标签拼接INSERT语句性能比单条循环插入高一个量级。还有一个新手常踩的坑就是#{}和${}混用的问题。MyBatis里#{}是预编译占位符会生成?能有效防止SQL注入${}是字符串拼接直接把值替换进SQL有注入风险。排序字段、表名这类不能用#{}的地方才用${}而且必须做白名单校验比如前端传来的排序列名必须是预定义的几个字段之一否则坚决不给过。3.3 核心接口逻辑演示以一个租赁周期最核心的流程为例生成账单。这个接口的业务逻辑值得仔细推敲因为涉及幂等、事务和状态控制多个因素。当一份合同生效后系统需要按账期生成租金账单。比如合同约定每月5号交租系统每月1号自动生成账单或者手动点击生成。生成逻辑的伪代码如下Override Transactional(rollbackFor Exception.class) public Result? generateBill(Long contractId, String period) { // 1. 查询合同信息合同不存在直接抛异常 Contract contract contractMapper.selectById(contractId); if (contract null) { throw new BizException(合同不存在); } // 2. 幂等校验该合同该账期是否已存在账单 int count billMapper.countByContractAndPeriod(contractId, period); if (count 0) { throw new BizException(该账期账单已生成请勿重复操作); } // 3. 查询该合同是否有未结清的上一期账单有则提示先结清 // 4. 组装账单数据写数据库 Bill bill new Bill(); bill.setBillNo(ZD System.currentTimeMillis()); bill.setContractId(contractId); bill.setPeriod(period); bill.setRentAmount(contract.getMonthlyRent()); bill.setTotalAmount(contract.getMonthlyRent()); bill.setPayStatus(0); // 未缴 bill.setCreateTime(new Date()); billMapper.insert(bill); return Result.success(); }这段代码里最关键的是第二步幂等校验。如果接口没有这个校验前端网络抖动导致用户重复点击就会生成两笔一模一样的账单后面对账就很麻烦。这个校验必须放在事务里且配合数据库层面的唯一索引contract_id period 联合唯一索引双保险才能彻底防止重复数据。退租结算这个接口是另一个业务复杂度较高的节点。退租时要做三件事计算未缴账单租金、水电费、物业费计算押金退还金额更新合同和房源状态。水电费退租当天还会涉及按天折算逻辑比较绕建议把所有金额计算封装成一个独立方法并写单元测试覆盖整数天、跨月、含阶梯价等场景。Mapper层的SQL设计上账单汇总统计是最能体现MyBatis价值的场景。一个月度经营报表的SQL大概长这样select idselectMonthlyReport resultTypemap SELECT DATE_FORMAT(period, %Y-%m) AS month, SUM(rent_amount) AS total_rent, SUM(water_fee electric_fee) AS total_utility, SUM(total_amount) AS should_receive, SUM(IF(pay_status 1, actual_amount, 0)) AS received_amount FROM bill WHERE period BETWEEN #{startPeriod} AND #{endPeriod} GROUP BY DATE_FORMAT(period, %Y-%m) ORDER BY month DESC /select这种SQL放在XML里调试时直接复制到MySQL客户端跑一遍看到结果和接口返回一致才能放心这也是我说MyBatis适合这类业务的原因之一。4. 前端Vue的实现要点4.1 环境准备与工程初始化Vue这边我推荐直接用Vue3 Vite Element Plus的组合相比Vue2的Options API组合式API在复杂业务页面里的组织能力更强。如果是拿到一份基于Vue2的源码核心逻辑也是相通的迁移难度不大。环境准备是很多初学者折在这里的第一步。安装Node.js建议用16或18版本不要追最新的大版本不然某些依赖包还没适配npm install直接报错。安装好之后把镜像源切换成国内源装依赖的速度会快很多。初始化一个Vite项目的命令很简单npm create vitelatest lease-admin -- --template vue cd lease-admin npm install npm install axios vue-router pinia element-plus echarts npm run dev前端工程目录我建议按这样组织api目录放所有接口定义assets放静态资源components放通用组件router放路由配置stores放状态管理utils放Axios封装和日期格式化等工具函数views放页面代码。这个结构在中小项目里足够清晰。4.2 Axios封装与路由配置Axios封装是一个前端工程质量的分水岭。直接在每个页面里this.$http.get这种写法虽然也能跑但一旦要统一处理token过期、错误提示、加载状态改动量会非常大。我习惯封装一个request.js模块import axios from axios; import { ElMessage } from element-plus; const request axios.create({ baseURL: /api, timeout: 10000 }); request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization 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.data; }, error { ElMessage.error(error.message || 网络异常); return Promise.reject(error); } ); export default request;这样做的好处是页面代码里不需要重复写错误弹窗只需要关心成功后的数据。路由方面的要点是权限控制和懒加载。登录成功后将用户角色信息存到本地在路由守卫里判断未登录用户跳转到登录页。页面对应的组件用动态import实现按需加载首屏体积不会臃肿。4.3 几个高频页面的实现套路房源列表页是系统里最有代表性的一个页面包含搜索条件栏、表格、分页、新增/编辑弹窗。搜索条件包括房源状态、楼栋、租金区间和关键字搜索按钮触发列表查询重置按钮清空条件。表单弹窗里用v-model绑定表单数据校验规则用Element Plus自带的rules实现提交时校验通过再调接口。合同列表页比房源列表复杂的地方在于状态标签和时间段的展示。合同状态用el-tag配合不同颜色区分进行中显示绿色已到期显示灰色已退租显示红色。日期范围用el-date-picker的daterange类型选择后转换成后端需要的开始和结束时间字符串。操作列里的“退租”按钮点击后弹出确认框提示将联动生成结算账单这种关键操作一定要二次确认。账单页面是另一个值得写一写的地方。页面顶部展示当前合同的费用汇总卡片下面表格列出每个账期的账单记录。批量生成账单按钮可以按账期批量生成指定合同的账单这个功能对拥有多套房源的房东特别实用。表格里的缴费操作需要支持部分缴纳比如本月账单1000元只交500元要记录实收金额并更新账单状态为部分缴纳。这部分前后端交互较多需要注意金额精度前端展示时用toFixed(2)后端用BigDecimal不要用double计算。统计看板用Echarts绘制几个核心图表每月租金趋势折线图、房源状态分布环形图、应收实收对比柱状图。数据接口返回的格式和Echarts的数据结构不匹配封装一个convert函数把后端数据转换成图表需要的格式这个函数放在utils里单独维护。5. 部署、联调与打包上线的完整路径5.1 本地联调配置本地开发时前后端分离最大的问题就是跨域。我推荐用Vite的proxy代理解决不要在后端写CORS全局配置这样更贴近生产环境的部署方式。在vite.config.js里配置export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } });这样前端代码里所有请求都写相对路径/api/xxx开发环境由Vite转发到后端的8080端口生产环境由Nginx转发前端代码不需要改一个字符。数据库初始化这一步要记得修改MySQL的时区配置。连接串里加上serverTimezoneAsia/Shanghai避免Java的Date和MySQL的时间相差8小时的问题。字符集统一用utf8mb4不然租客姓名里有个生僻字可能存不进去。5.2 生产环境打包部署打包部署有两种主流方案各有利弊。第一种是前后端分离部署这也是推荐方案。前端执行npm run build生成dist目录交给Nginx托管静态文件。后端执行mvn clean package -DskipTests生成jar包用java -jar启动。Nginx配置里需要把/api前缀的请求反向代理到后端端口server { listen 80; server_name your-domain.com; location / { root /opt/lease-admin/dist; 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; } }这段配置里的try_files非常重要因为Vue是单页应用前端路由用的是history模式刷新页面时如果Nginx找不到对应的静态文件路径就会返回404。加上try_files让所有路径都回退到index.html由前端路由接管。第二种方案是把前端打包产物放到后端的resources/static目录下打成单个jar包运行。这种方式适合部署到一台小服务器上不想装Nginx的场景。但要注意使用这种方式有些特殊情况需要处理比如接口路径和静态资源路径的区分SpringBoot会自动处理但每次前端更新都要重新打包后端迭代效率低一些。5.3 从源码到跑起来的完整操作顺序拿到一套源码很多人不知道从哪里下手。我整理一套稳妥的顺序先看数据库再启动后端最后跑前端。第一步用Navicat新建数据库导入项目里的SQL文件。导入后检查关键表的数据确认有基础数据可以测试。第二步打开application.yml修改数据库账号密码。第三步用IDEA打开后端代码等Maven依赖下载完成如果依赖下载慢就配置阿里云镜像。第四步启动Application类看到Tomcat started on port 8080就说明后端起来了。第五步打开前端代码npm install安装依赖npm run dev启动开发服务器。这个顺序背后的逻辑是先确保数据源没问题再验证后端接口通最后才让前端去对接。如果先跑前端再启动后端页面上报一堆接口错误时会分不清是前端问题还是后端没起好排查效率很低。6. 常见问题排查与避坑速查表6.1 后端启动失败与数据库连接问题启动后端时报错的情况我把最常见的几类整理成一个速查表方便直接对照。现象可能原因解决方案启动时报错Failed to configure a DataSource连接数据库的配置不对检查application.yml中的url、username、password确认MySQL服务已启动com.mysql.cj.jdbc.Driver无法加载驱动依赖版本过低换成mysql-connector-java 8.0.x版本数据库连接报SSL错误本地MySQL未配置SSL证书连接串加useSSLfalse端口被占用8080被其它进程占用换端口或在yml里改server.portMyBatis报Invalid bound statementMapper接口和XML没绑定检查mapper-locations路径是否正确XML里的namespace是否对应接口全路径MySQL连接报SSL错误这个特别常见尤其是用新版驱动连旧版数据库时。错误信息会提示ssl connection error最简单的处理方式就是连接串里加useSSLfalse。数据库是云数据库时可能还需要allowPublicKeyRetrievaltrue不然连上了也验证不过去。6.2 前端常见报错和跨域问题前端最常见的问题是npm install阶段就失败了。先检查Node版本Vite5要求Node18以上Element Plus要求Vue3版本不对全是坑。再检查镜像源用npx nrm ls看当前源是哪个换成国内源基本能解决一半的安装问题。跨域问题在开发环境用proxy解决后生产环境还要再检查一遍。打包后部署到Nginx如果忘了配/api反向代理前端页面上所有接口都会404或500。排查方法很简单打开浏览器开发者工具看Network里请求的URL是相对路径还是完整路径如果是完整路径打到前端服务器上说明代理配置没生效。路由刷新404的问题上文提过就是Nginx缺少try_files配置。这个问题之所以容易漏是因为开发环境里Vite帮我们处理了线上环境Nginx不认识Vue的history路由一刷新就找不到文件。6.3 MyBatis特定坑位MyBatis用久了有几个特定坑值得单列出来。字段映射问题是高频踩坑点。数据库字段是create_timeJava属性是createTime如果没有开启mapUnderscoreToCamelCase或者没写ResultMap查询结果里这个字段就是null。我建议项目里统一开启驼峰映射同时XML里所有select都写完整的列名不要用select *。缓存问题也很隐蔽。MyBatis一级缓存默认开启同一个SqlSession内重复查询相同SQL会命中缓存这在大多数情况下能提升性能。但二级缓存如果配置不当在多表关联查询的场景下容易出现脏数据——比如查询房源信息时关联了租客表之后租客数据更新了房源查询的缓存还没失效返回的数据就是旧的。我的经验是列表页和统计查询这种数据实时性要求高的场景别开二级缓存或者配置合理的刷新间隔。最后是动态SQL里条件不生效的坑。用 标签时如果判断条件是某个参数不为空但前端传了空字符串这个条件也会执行结果就是SQL里多了一个毫无意义的条件。解决办法是在 里同时判断! null and ! 这个小细节能省下大量排查时间。6.4 账单和金额计算相关避坑账单模块的坑大多出在幂等和精度上。生成账单的接口一定要做幂等校验我在第3节已经展示过了。这里再补充一点除了代码层的幂等校验数据库层面也要建好联合唯一索引两个层面互相兜底。否则高并发请求或用户连点的情况下代码里的校验可能查不到还没提交到数据库的数据导致重复插入。金额计算一律用BigDecimal。举个例子月租金1200元住了45天按天折算金额是1200 / 30 * 45 1800元这个计算如果用double做会得到1799.9999999999998展示给用户就露馅了。BigDecimal配合divide方法时指定精度和舍入模式能保证结果完全可控。还有一点是关于付款方式。如果系统支持押一付一、押一付三这类规则创建合同时就要生成多期账单这时候建议用主从表设计合同主表记录总押金账单子表记录每期应缴金额和状态。不要试图在一个字符串字段里塞多期数据后面统计和结清时处理起来极其痛苦。我个人在实际操作中的体会是这套系统的难点根本不在Java或Vue的语法层面上而在业务状态的流转设计和数据一致性控制上。把房源状态、合同状态、账单状态之间的联动关系想清楚把幂等、事务、精度这些细节处理干净SpringBoot Vue MyBatis MySQL就是一个非常顺手的实现工具。最后再分享一个小技巧项目开发阶段顺手把每个核心表的状态枚举值、每个接口的出入参示例写在一个markdown文档里联调和后期维护能省下不少沟通成本这个习惯我一直用到现在。