
1. 先说说租赁管理到底乱在哪我做这个企业级房屋租赁管理系统起因其实挺俗的——帮一个手上管着两百多套房的房东朋友整理账目发现他还在用Excel加微信聊天记录管理租约。每个月水电费要手动算哪个房间到期了得翻合同租客欠费了只能靠记忆催收退房时押金该扣多少全凭嘴说。这种状态撑到几十套房就已经是极限了到两百套的时候光是某套房现在的租客是谁这个问题都得打三个电话才能确认。这其实就是租赁管理行业长期存在的痛点房源信息分散、合同状态不透明、账单催收靠人工、租客资料缺失。市面上不是没有SaaS产品但要么按年收费不便宜要么功能大而全但操作繁琐对于想自己掌握数据、或者有二次开发需求的团队来说一套能部署在自己服务器上的开源系统反而更实用。这就是我写这套基于SpringBootVueMyBatisMySQL的系统源码的初衷。这套系统不是什么花哨的demo而是把真实租赁业务里最高频的几条链路走通了房源录入与状态管理、租客信息维护、合同签约与到期提醒、账单生成与收款登记、押金管理、退房结算。技术栈也是目前国内中小团队最熟悉的一套组合招人好招出了问题社区资料也多属于稳字当头的选择。如果你正准备入门企业级项目开发或者需要一个能直接改改就能用的租赁管理系统底座这套源码的架构思路和实现细节应该能给你不少参考。下面我按照从设计到落地的顺序把关键环节逐个拆开讲。2. 技术选型这套架构的每一环都是为什么2.1 前后端分离的边界划分很多刚接触这个项目的人会问为什么一定要SpringBootVue前后端分离而不是像传统JSP那样一个工程搞定我的回答是租赁管理系统的使用场景决定了它必须分离。这套系统的使用者有两类一类是管理员在办公室用电脑浏览器操作另一类是租客或者房东本人可能会在手机上查看账单、提交报修。前后端分离之后Vue打包的静态页面可以部署在Nginx上SpringBoot只提供纯JSON接口未来如果要出小程序或者App后端接口可以直接复用不用重写业务逻辑。而且前后端并行开发效率也高前端调Mock数据后端写接口文档两边互不阻塞。SpringBoot在这套架构里的定位是业务中枢负责接收请求、处理业务规则、调用数据库。Vue则是展示层负责把数据渲染成表格、表单、图表并把用户操作转成API请求。两者之间通过RESTful风格的JSON接口通信。2.2 MyBatis和MySQL的配合方式数据库选MySQL没什么悬念开源、稳定、资料多租赁业务的数据量在单机MySQL的承受范围内绰绰有余。真正值得聊的是为什么持久层选MyBatis而不是JPA或者MyBatis-Plus。我选MyBatis的核心原因是对SQL有完全的控制权。租赁业务里有大量复杂查询比如查询所有合同即将到期且未续租的房源需要关联房源表、合同表、租客表还要做日期函数比较。这种SQL用MyBatis写在XML里一眼就能看明白执行计划遇到慢查询直接复制到Navicat里跑一下 EXPLAIN 就能定位问题。JPA虽然开发快但一旦遇到复杂查询自动生成的SQL往往不够聪明排查问题的成本反而更高。提示MyBatis-Plus我也在部分模块里用过单表CRUD确实省事但涉及多表关联和自定义分页时最终还是回到XML手写SQL。所以这套源码里保留了纯MyBatis的写法目的就是让阅读代码的人能看清每一条SQL而不是被框架封装的黑盒搞晕。2.3 为什么不用更重的微服务方案有人可能会问标题都写了企业级为什么不用Spring Cloud Alibaba那一套我的判断标准很简单看业务复杂度。租赁管理系统的核心是房源、合同、账单、租客几个模块日均请求量可能都不到一万这种体量上微服务纯粹是给自己找麻烦。分布式事务、服务注册发现、配置中心、链路追踪每一个组件都是运维负担。对于100人以下团队、单机部署足矣的业务场景一个单体SpringBoot应用就是最合理的架构。它部署简单一个jar包搞定、调试方便本地直接跑、没有网络开销。这套源码在架构上保留了后续拆分的可能性——模块之间通过Service接口隔离如果未来业务量真的大到需要拆分把合同模块、账单模块单独拎出来变成服务改动成本是可控的。3. 数据库设计租赁业务的心脏3.1 核心表结构拆解数据库设计决定了业务逻辑能走多远。我建表的原则是能拆的状态字段绝不合并能用外键逻辑关联的绝不用物理外键避免删除时的耦合金额字段统一用DECIMAL不用FLOAT避免精度丢失。这套系统里最核心的几张表如下表名职责关键字段house房源信息id, house_no, address, area, floor, layout, rent_price, statustenant租客信息id, name, phone, id_card, emergency_contact, remarkcontract租赁合同id, house_id, tenant_id, start_date, end_date, rent_price, deposit, statusbill账单记录id, contract_id, bill_month, rent_amount, water_amount, electric_amount, total_amount, statuspayment收款记录id, bill_id, pay_amount, pay_time, pay_type, operatorrepair_order报修工单id, house_id, tenant_id, description, status, create_time, finish_timehouse表和contract表的关系是一对多一套房在不同时间段会有多份合同但同一时间只能有一份生效合同。这个当前生效合同的判断我在业务层处理而不是在表里冗余一个字段因为一旦冗余就面临数据一致性问题——如果某天合同状态更新了房源表里存的当前合同ID可能就错了。tenant表单独建不挂在contract下面原因是同一个租客可能续租多次每次续租生成新合同但租客还是同一个人。把租客独立出来可以保留完整的租住历史对房东评估租客信用也有帮助。3.2 合同与账单的状态流转状态字段的设计是我比较满意的部分。contract表里的status字段一共有五个值PENDING待生效、ACTIVE生效中、EXPIRED已到期、TERMINATED已终止、RENEWED已续租。为什么需要RENEWED这个状态很多粗放的系统里续租就是直接把原合同的结束日期改掉这样做的后果是历史合同记录被篡改将来查账的时候根本说不清楚这套房到底是什么时候签的、中间有没有断租。我的处理方式是续租时把原合同标记为RENEWED同时生成一份新合同原合同的结束日期保持不变新合同的开始日期是原合同结束日期的次日。这样合同的完整生命周期都保留下来数据可追溯。账单的状态流转相对简单UNPAID待支付、PARTIAL部分支付、PAID已支付、OVERDUE已逾期。OVERDUE不是单独的一个字段而是通过截止日期已过且status ! PAID这个条件动态算出来的这样不用每天跑定时任务去更新状态查询的时候加一个判断条件即可。4. 后端实现要点从实体到业务逻辑4.1 统一返回与全局异常处理SpringBoot后端代码看起来最简单、但最容易做乱的就是接口返回格式。每个人都有自己的一套写法有人直接返回Map有人返回null表示失败时间一长前端对接的人就疯了。我在这个项目里定义了一个统一的返回结构 R 包含code、message、data三个字段public class RT { private Integer code; private String message; private T data; public static T RT ok(T data) { RT r new R(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T RT fail(String message) { RT r new R(); r.setCode(500); r.setMessage(message); return r; } }代码很简单但配合全局异常处理器之后威力就出来了。业务层抛出BusinessException比如该房源当前已有生效合同无法再次签约全局异常处理器捕获后转换成R.fail返回给前端HTTP状态码依然保持200。这样做的好处是前端axios拦截器只需要处理HTTP层的错误业务层的错误统一走data.code判断逻辑清晰不打架。4.2 房源与租客管理的查询逻辑房源列表的查询是后端最典型的一个接口。除了基础的分页还需要根据状态空置/已租/维修中、户型、价格区间、区域等多个条件组合筛选。这种场景用MyBatis的动态SQL最合适select idselectHousePage resultTypecom.rent.entity.House SELECT h.*, (SELECT t.name FROM contract c JOIN tenant t ON c.tenant_id t.id WHERE c.house_id h.id AND c.status ACTIVE) AS currentTenantName FROM house h where if teststatus ! null and status ! AND h.status #{status} /if if testminPrice ! null AND h.rent_price gt; #{minPrice} /if if testmaxPrice ! null AND h.rent_price lt; #{maxPrice} /if if testkeyword ! null and keyword ! AND (h.house_no LIKE CONCAT(%, #{keyword}, %) OR h.address LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY h.create_time DESC /select这个SQL里有个小技巧值得说一下currentTenantName这个字段用子查询从合同表和租客表关联查出来。为什么不直接JOIN因为一套房可能有多条合同记录如果直接JOIN合同表会出现一对多导致的分页数据重复问题。用子查询只取一条配合statusACTIVE条件就保证了每个房源只返回一条数据分页总数也是准确的。租客管理相对简单主要是增删改查加一个黑名单标记。黑名单这个功能是后来加的实际使用中发现有些租客有拖欠记录或者扰民被投诉房东之间需要共享这些信息。我在tenant表里加了一个blacklist字段列表页可以用红色标注黑名单租客签约的时候后端也会校验如果租客在黑名单里直接拒绝创建合同。4.3 账单周期计算与逾期处理账单模块是这套系统里业务逻辑最重的部分也是房东最关心的功能。每个月的水电费、房租、物业费怎么算怎么生成账单怎么登记收款这些规则如果不理清楚代码写出来一定是乱的。我的设计思路是账单按月生成但生成方式分两种。第一种是固定账单在合同创建时根据合同周期自动生成每个月的一笔房租账单第二种是变动账单每个月底根据租客上报的水电表读数手动或批量生成当月的水电费账单。具体到按月自动生成房租账单的定时任务核心逻辑是这样Service public class BillGenerateTask { Scheduled(cron 0 0 2 1 * ?) // 每月1号凌晨2点执行 public void generateMonthlyBills() { // 1. 查询所有状态为ACTIVE的合同 ListContract activeContracts contractMapper.selectByStatus(ACTIVE); String currentMonth LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy-MM)); for (Contract contract : activeContracts) { // 2. 判断当月账单是否已存在防止重复生成 Bill existBill billMapper.selectByContractAndMonth(contract.getId(), currentMonth); if (existBill ! null) { continue; } // 3. 创建账单房租金额取合同约定的租金 Bill bill new Bill(); bill.setContractId(contract.getId()); bill.setBillMonth(currentMonth); bill.setRentAmount(contract.getRentPrice()); bill.setTotalAmount(contract.getRentPrice()); bill.setStatus(UNPAID); bill.setDueDate(LocalDate.now().plusDays(7)); // 账单生成后7天内支付 billMapper.insert(bill); } } }水电费账单的生成逻辑不同它需要先录入本期表底数和上期表底数然后计算差值乘以单价。这里有个容易踩的坑水电表的底数必须和合同关联而不是和房源关联。因为两任租客交接的时候表底数是连续的如果只按房源存一个底数上一任租客退房时的底数就是下一任租客的起算底数这个数如果被覆盖了账单就对不上了。我的做法是在contract表里存check_in_meter和check_out_meter两个字段结算时只针对当前合同周期内的用量做计算。逾期处理我用的方案是动态判定邮件提醒不搞自动罚息。动态判定就是前面说的查询账单时如果截止日期已过且状态未支付就显示为逾期邮件提醒是每天扫一次逾期未支付且超过3天的账单给房东发汇总邮件。为什么不做自动罚息因为在实际业务中很多房东和租客之间有口头约定允许宽限几天自动罚息容易引发矛盾不如把逾期状态展示出来由房东决定怎么处理。4.4 登录与权限控制登录认证这块我用了最简单的方案Spring Boot JWT 拦截器。没有引入Spring Security那套重量级框架原因是这套系统的角色只有两种——管理员和普通操作员权限控制的颗粒度没有细到按钮级别用一个自定义拦截器校验JWT和角色就够了。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains(/login)) { return true; } String token request.getHeader(Authorization); if (StringUtils.isBlank(token) || !JwtUtil.validate(token)) { response.setStatus(401); return false; } // 从token中解析角色如果接口需要管理员权限则校验 String role JwtUtil.getRole(token); if (request.getRequestURI().startsWith(/admin/) !ADMIN.equals(role)) { response.setStatus(403); return false; } return true; } }JWT的优点是无状态后端不用存session适合前后端分离的场景。缺点也很明确token一旦签发在过期之前无法主动让它失效。所以在用户修改密码的时候我用了一个简单的处理——把密码修改时间戳编码进token拦截器里比较这个时间和数据库里的记录如果token里的时间早于数据库里的修改时间就认为token已失效。这个方案在不需要实时踢人下线的场景下够用了。5. 前端Vue实现页面背后的交互逻辑5.1 路由与权限拦截前端用Vue 2 Element UI这套组合Vue 2虽然官方已停止维护但存量项目最多的还是它而且Element UI的组件齐全表格、表单、弹窗、日期选择器这些租赁管理页面高频使用的组件都有现成的。前端路由分两块登录页和主布局页。主布局页里通过children嵌套各个业务页面侧边菜单根据路由配置自动生成。这样做的好处是新增页面只需要在路由表里加一条记录菜单就会同步出现不需要改动侧边栏组件。路由守卫是关键环节router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() return } if (!token) { next(/login) return } // 已登录但没有角色信息时先获取用户信息 if (!store.state.userInfo) { store.dispatch(fetchUserInfo).then(() { next() }).catch(() { next(/login) }) return } next() })这段代码解决了一个很常见的体验问题用户刷新页面后Vuex里的用户状态丢失导致路由守卫误判为未登录。处理方式是每次刷新后在守卫里检查store如果为空就重新请求用户信息接口拿到数据后再放行。5.2 核心页面组件的实现思路房源管理页面是整个前端最核心的页面。它由一个搜索栏、一个表格、一个分页器组成。搜索栏的条件和后端接口的查询参数一一对应使用Element UI的el-form el-select el-input实现。表格里每个房源行都有一组操作按钮编辑、查看合同、生成账单、设为维修。这里有一个我踩过的坑Element UI的el-table在数据量超过200行时如果不做分页渲染会有明显卡顿。解决方案是前端必须使用分页组件每页10条或20条。同时el-table-column上不要塞太多自定义template能用formatter格式化就尽量用formatter减少虚拟DOM的创建数量。合同管理页面用了一个比较实用的交互左侧是房源列表小尺寸右侧是选中房源的合同时间线。点击左侧某个房源右侧通过时间线组件展示这个房源的历史合同每份合同显示起止日期、租客姓名、租金和状态。这个设计在电脑端特别直观房东一眼就能看出这套房有没有空档期——相邻两份合同的结束日期和开始日期之间如果有间隔就是房子空置的时间。账单管理页面是操作频率最高的页面。我设计了两种视图按账单列表查看和按房源账单卡片查看。列表视图适合月底核对所有账单卡片视图适合单个房源整年账单的纵向对比每个月的账单金额、支付状态都用颜色区分已支付绿色、待支付橙色、逾期红色很容易发现异常。5.3 前后端联调的经验联调阶段最容易出的问题就是字段命名不一致。后端的实体类是驼峰命名数据库字段是下划线命名MyBatis的map-underscore-to-camel-case可以自动做转换但这个配置只对resultType自动映射生效。如果查询结果是Map类型或者字段名在XML里有别名前端拿到的字段名可能就不是驼峰形式的。我在项目里统一约定所有接口返回的JSON字段都是驼峰命名前端axios不做二次转换遇到字段对不上就查后端代码而不是在前端写一堆映射逻辑。另一个联调经验是接口的日期格式问题。Java后端返回LocalDate默认是数组格式或者ISO格式前端new Date()解析可能会出问题。我在application.yml里做了全局配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这样所有日期字段统一返回字符串格式前端直接展示不需要再做格式化。前端传给后端的日期参数统一用yyyy-MM-dd格式后端用DateTimeFormat注解接收避免因为格式不一致导致400错误。6. 部署配置、以及我踩过的坑6.1 从零到上线的环境准备整套系统的部署环境是Linux服务器 Nginx JDK 8 MySQL 8.0都是生产环境最常见的组合。部署步骤我整理成了一份清单照着走基本不会出问题安装JDK 8并配置JAVA_HOME环境变量安装MySQL 8.0创建数据库rent_db执行项目的init.sql初始化脚本修改后端application-prod.yml里的数据库连接信息用户名、密码、IP使用Maven打包mvn clean package -Dmaven.test.skiptrue将打好的jar包上传到服务器启动java -jar rent-system.jar --spring.profiles.activeprod前端执行npm install npm run build把dist目录上传到Nginx的html目录配置Nginx反向代理将/api开头的请求转发到localhost:8080Nginx配置这段是关键前后端分离的部署路由转发写错了页面就白屏server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; # 前端路由History模式需要这个配置 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; } }注意proxy_pass后面那个斜杠http://127.0.0.1:8080/ 这种写法会把location前缀 /api/ 去掉再转发。也就是说前端请求 /api/house/list后端实际收到的是 /house/list所以后端的Controller路由不需要带 /api 前缀。这个细节很多第一次部署的人会搞混如果忘了加斜杠后端收到的就是 /api/house/list直接404。6.2 实际运行中遇到的高频问题第一个坑是数据库连接池的时区问题。MySQL 8.0的驱动对时区敏感如果连接串里不指定serverTimezone启动时就会报错。我在jdbc连接串里加了serverTimezoneAsia/Shanghai问题解决。另外MySQL 8.0默认的认证插件是caching_sha2_password如果JDBC驱动版本太旧会连接失败用mysql-connector-java 8.0以上版本就没问题。第二个坑是定时任务重复执行。SpringBoot自带的Scheduled在单机部署下没问题但如果你为了高可用部署了两个实例每个月1号凌晨2点两个实例会同时生成账单导致重复数据。我的解决办法是在数据库层面加唯一索引bill表上建了(contract_id, bill_month)的唯一索引第二个实例插入时触发DuplicateKeyException代码里捕获这个异常直接跳过。这是最稳妥的幂等方案。第三个坑是文件上传路径问题。合同扫描件、租客身份证照片这些附件如果直接存到服务器本地目录打包部署时很容易出现目录不存在导致上传失败。我统一走了一个配置项file.upload-dir部署时在服务器上手动创建目录并配置好权限。生产环境如果条件允许更建议直接对接对象存储但这套源码为了保持轻量还是用的本地存储。第四个坑是内存溢出。这个坑出现在一个管理了500多套房、累计上万张合同和账单图片的客户环境里。启动参数如果不设置默认堆内存可能只有物理内存的四分之一而上传图片预览时一次性加载太多图片就会OOM。我在部署文档里加了一条启动命令java -Xms512m -Xmx1024m -jar rent-system.jar --spring.profiles.activeprod给JVM设置固定的初始堆和最大堆避免运行时频繁扩容也防止内存失控。7. 这套源码还可以往哪些方向扩展系统上线稳定运行之后如果你还有余力我在架构和代码层面都预留了一些扩展点可以顺着这几个方向继续做。第一个方向是消息通知能力。目前催租靠人看列表如果接入企业微信或钉钉机器人账单逾期时自动推送消息到管理员的手机上体验会好很多。实现上也不复杂写一个NotifyService接口先实现一个钉钉机器人的实现类在逾期扫描的地方调用即可。第二个方向是数据报表可视化。现在系统里有大量的账单和合同数据但还没有充分利用。可以用ECharts做几个关键的图表每月租金收入趋势、房源空置率变化、逾期账单金额分布。这些数据后端已经有接口可以汇总前端只是需要加一个Dashboard页面的问题。第三个方向是电子合同与在线签约。目前合同还需要线下打印签署再上传扫描件流程上还是有断点。如果要接入电子签平台比如法大大或者上上签需要在合同创建后调一个第三方接口生成签署链接发给租客租客完成签署后平台通过回调通知系统更新合同状态。这部分主要是接入成本架构上新增一个SignService接口隔离第三方依赖即可。我只建议在业务真正有需求的时候再扩展这些功能。租赁管理系统的核心永远是把账算清楚、把合同管好、把租客服务好任何花哨的功能都不如这几个基础模块做得扎实重要。我自己在维护这套系统的过程中最深的体会是技术选型不需要追新稳定可靠、容易维护、团队熟悉这三条比什么都重要。最后再分享一个小技巧——把项目中所有涉及金额计算的逻辑都集中在MoneyUtil工具类里统一用BigDecimal操作不要散落在各个Service里。这个习惯帮我避免了好几次精度问题也让我在一处修改计费规则时不用满项目找代码。