1. 为什么最终选了Spring Boot做租赁系统
先交代一下背景。前两年有个做房产中介的朋友——这里就叫某房产公司吧——找我说他们公司的房源信息、租客合同、租金账单还全部堆在Excel里,业务量一大,经常出现同一套房被两个业务员同时带看、租金收重或漏收、合同到期没人提醒的乱子。他们想要一套能管房源、管租客、管合同、管账单的系统,但预算有限,也不打算养专职运维。那段时间我正好在整理一套基于Spring Boot的管理系统模板,就把这个需求接了下来。
技术选型上,其实我先排除掉了几条路。不需要上微服务,租赁管理这种业务,用户量撑死了几千人同时在线,单体应用完全够用;不需要上Python或者PHP,虽然它们也能做,但招人维护、后续扩展、生态完整度都不如Java体系顺手。最后落地的是Spring Boot 2.7 + MyBatis Plus + MySQL + Redis + Vue(前后端分离)。为什么是这套组合,后面每个章节我都会结合具体环节讲清楚。
这里先把这个系统的核心价值说透:它不是一个花架子Demo,而是一个能直接给中介门店日常运营使用的业务系统。你要学的不是CRUD本身,而是房源状态流转、合同生命周期、租金账期这些租赁业务逻辑是怎么落到代码里的。这也是这篇文章最适合的人群——想用Spring Boot做完整Web项目、想搞明白真实业务系统怎么设计、或者正在做相关毕业设计/面试项目的开发者。文章里我会从数据建模、权限设计、核心业务代码到上线踩坑,完整复盘这套系统的实现过程。
2. 数据建模是租赁系统的命根子:六大核心实体与业务关系
系统上线半年后我最大的体会是:租赁系统的地基在数据模型,不在代码。很多功能做到一半发现不好扩展,回头一看都是表结构当初没设计好。这套系统的核心实体一共六个:房屋表、租客表、合同表、账单表、收据表、系统用户表。它们之间的关系一句话就能概括——一个房屋可对应多个历史合同,一份合同对应一个租客和多条账单,一条账单可以有多条收据记录。
2.1 房屋表:把"房"和"房源"分开建模
很多人第一次做租赁系统,习惯把一套房子的所有信息塞进一张表里。房东信息、租客信息、合同信息全搅在一起,看起来简单,实际上后续根本没法维护。我把设计拆成了两层:房屋的基本属性层和租赁状态层。
-- 房屋基础信息表 create table house ( id bigint primary key auto_increment, house_code varchar(32) not null comment '房屋编号', community_name varchar(64) comment '小区名称', building_no varchar(16) comment '楼栋号', unit_no varchar(16) comment '单元号', room_no varchar(16) comment '房号', area decimal(8,2) comment '建筑面积(平米)', layout varchar(32) comment '户型,如两室一厅', orientation varchar(16) comment '朝向:南/北/东/西', decoration varchar(32) comment '装修情况', floor_number int comment '所在楼层', total_floor int comment '总楼层', rent_price decimal(10,2) comment '挂牌租金(元/月)', deposit_months int default 1 comment '押金月数', status tinyint default 0 comment '0未出租 1已出租 2下架 3维修中', owner_name varchar(32) comment '房东姓名', owner_phone varchar(20) comment '房东电话', created_time datetime, updated_time datetime );状态字段单独拎出来,而不是用"删除房源"来表示下架,是因为租赁业务里"下架"和"删除"语义完全不同。下架的房子还在库里,可能过两个月重新上架;删除就真的是数据没了,后续做历史统计时会非常麻烦。
租客表相对简单,但有个点必须注意:手机号和身份证号建议做加密存储或至少脱敏展示。我在系统里用了SM4对称加密存身份证号,列表页展示时用工具类脱敏成110***********1234这种格式,既保证业务可用又降低数据泄露风险。
2.2 合同表:用状态机管理合同生命周期
合同是租赁系统的核心单据。一套房从挂牌到租出去,中间会经历"待签约—生效中—已到期—已退租—违约终止"等多个状态。这些状态不是随便定义个字符串就完事的,它们之间有明确的前后关系和流转方向。
我在设计合同表的时候,给状态字段加了注释和约束:
create table contract ( id bigint primary key auto_increment, contract_no varchar(32) not null comment '合同编号', house_id bigint not null comment '关联房屋ID', tenant_id bigint not null comment '关联租客ID', start_date date comment '合同开始日期', end_date date comment '合同结束日期', monthly_rent decimal(10,2) comment '月租金', deposit_amount decimal(10,2) comment '押金金额', payment_cycle tinyint default 1 comment '付租周期:1月付 2季付 3半年付 4年付', status tinyint default 0 comment '0草稿 1生效中 2已到期 3已退租 4违约终止', sign_date date comment '实际签约日期', remark varchar(255), created_time datetime, updated_time datetime );状态流转我单独封装了一个服务类来管,不允许业务代码里到处直接修改status字段。因为"已到期"和"已退租"虽然看起来都是合同结束,但对房源的影响不同:已到期的房子,系统会自动把房屋状态改成"未出租",等着重新挂牌;已退租则要走退租流程,涉及押金退还、水电费结清等操作。如果直接在Service里写contract.setStatus(3),用不了几次状态就乱了。
2.3 账单与收据:一份合同N条账,一条账M次收
账单和收据的表设计是租赁系统区别于普通CRUD项目最明显的地方。很多新手容易把"收钱记录"直接做成合同的一个字段,比如"已收租金"、"已交押金"。这样做在单个合同下还能应付,一旦涉及部分缴费、逾期、提前退租退押金,根本算不清账。
正确的做法是账单和收据分离:
create table bill ( id bigint primary key auto_increment, contract_id bigint not null comment '关联合同', bill_type tinyint not null comment '1租金 2押金 3水电费 4物业费 5违约金 6维修费', period_start date comment '账单周期开始', period_end date comment '账单周期结束', amount decimal(10,2) not null comment '应收金额', paid_amount decimal(10,2) default 0 comment '已收金额', status tinyint default 0 comment '0未缴 1部分缴纳 2已缴清 3已减免 4已作废', due_date date comment '缴费截止日', created_time datetime ); create table receipt ( id bigint primary key auto_increment, bill_id bigint not null comment '关联账单', receipt_no varchar(32) comment '收据编号', pay_amount decimal(10,2) not null comment '本次收款金额', pay_method tinyint default 1 comment '1现金 2微信 3支付宝 4银行卡', pay_time datetime comment '收款时间', operator_id bigint comment '操作员用户ID', remark varchar(255), created_time datetime );为什么账单和收据非要两张表?因为真实业务里经常出现"一个账单分三次收完"的情况。租客资金紧张,跟房东商量先付一半,剩余的下周再付;或者公司代付租金,个人再补押金。一张收据表把这些都记录得清清楚楚,后面做财务报表、对账都非常方便。
数据建模这块我当时踩过一个坑:合同表里直接用end_date字符串存储日期,导致后续查询"未来30天内到期合同"时,日期比较只能靠字符串排序,格式一不统一就出bug。后来统一改成date类型,查询就靠谱多了。这个细节在后面的到期提醒功能里尤其重要。
3. 权限与多角色:房东、租客、运营人员各管一亩三分地
租赁系统面对的是一屋子角色:总管理员、门店运营、房东、租客、财务,甚至还可能有维修工。每个角色能看的数据和能执行的操作完全不一样。租客不能看到其他租客的合同;运营人员能改房源信息但看不到公司财务汇总;房东只能看自己名下房屋的账单和收款记录。
3.1 基于RBAC模型的权限设计
我用的权限方案是经典的RBAC(基于角色的访问控制),三张核心表:用户表、角色表、用户角色关联表。在此基础上扩展了菜单权限表和按钮权限表。
public class LoginUser implements UserDetails { private Long userId; private String username; private String realName; private List<String> roles; // 角色标识 private List<String> permissions; // 权限标识,如 "house:add", "bill:pay" }登录成功后,把这些信息放进Redis缓存,并生成JWT令牌返回前端。每个接口在进入Controller之前,由拦截器统一校验权限。代码层面我用了一个简单的自定义注解配合Spring Security的@PreAuthorize实现:
@GetMapping("/house/list") @PreAuthorize("hasAnyAuthority('house:query', 'house:add')") public Result listHouse(@RequestBody HouseQuery query) { return Result.ok(houseService.pageQuery(query)); }权限字符串的命名规则是"资源:操作"。比如house:query是查看房源,house:add是新增房源,bill:pay是收款操作。前端根据这个权限标识控制按钮显隐,后端再校验一次,双层保险。
3.2 数据权限:同一个接口,不同角色看到不同范围
角色权限管的是"能做什么",数据权限管的是"能看到哪些数据"。这套系统里有三类典型的数据范围要求:
| 角色 | 房源数据范围 | 账单数据范围 |
|---|---|---|
| 总管理员 | 全公司所有房源 | 所有账单 |
| 门店运营 | 本门店负责的房源 | 本门店产生的账单 |
| 房东 | 自己名下房源 | 自己名下房源账单 |
| 租客 | 仅当前租赁合同关联房源 | 仅自己合同的账单 |
实现方式不复杂,核心是在查询方法里根据当前登录用户动态拼接过滤条件。我用MyBatis Plus的拦截器做了数据权限插件,遇到特定注解的方法自动在SQL后面追加AND house.owner_id = 当前用户ID之类的条件。这种做法的好处是业务Service代码不用到处写数据过滤逻辑,坏处是拦截器里SQL拼接要非常小心,防止注入或者拼接错误。
3.3 密码与令牌的细节处理
密码存储用BCrypt加密,不用MD5,原因不用多说——MD5撞库成本太低。JWT密钥放在配置文件中并通过环境变量注入,不上传到代码仓库。Redis里存一份token黑名单,用户修改密码或强制下线时把token标记失效。
这里有一个我后来才补上的细节:权限不是一成不变的。比如租客退租后,他对该房源账单的查看权限应该自动回收。我加了一个定时任务,每天凌晨扫描合同状态,凡是状态变成"已退租"的合同关联租客账号,自动禁用。这样避免了"租客都搬走了还能看到历史账单"的尴尬情况。
权限这块最容易被忽略的是"越权改数据"的漏洞。比如租客传了个contractId=10086的请求,如果后端不做归属校验,他可能修改别人的合同。所以所有关联查询我都坚持加一层归属断言:
public void updateContract(Contract contract) { // 断言当前登录用户对该合同有操作权限 assertContractPermission(contract.getId(), currentUserId()); contractService.updateById(contract); }虽然代码看起来"多了一步",但这是线上系统防越权最有效的兜底方案。
4. 核心业务代码落地:房源流转、合同到期提醒、租金账单生成
权限和数据模型都跑通之后,真正让人头疼的是业务代码的组织。这里我挑三个最有代表性的功能讲:房源上下架与租赁状态流转、合同到期自动提醒、租金账单自动生成与逾期计算。这三个功能基本能代表一套租赁系统80%的业务复杂度。
4.1 房源状态流转:一个状态机的工程化实践
房源状态的流转不是随便set一个新值就算完。从"未出租"到"已出租",必须有一份生效中的合同;从"已出租"回到"未出租",合同状态必须是"已到期"或"已退租"。我在实现时做了一个专门的状态机服务:
@Service public class HouseStatusService { public void rentOut(Long houseId, Long contractId) { House house = houseMapper.selectById(houseId); Contract contract = contractMapper.selectById(contractId); if (house.getStatus() != HouseStatus.IDLE) { throw new BizException("房屋当前状态不允许出租"); } if (contract.getStatus() != ContractStatus.BEGINNING) { throw new BizException("合同状态不是生效中,无法关联房屋"); } int updated = houseMapper.compareAndSetStatus(houseId, HouseStatus.IDLE, HouseStatus.RENTED); if (updated == 0) { throw new BizException("房源状态已发生变化,请刷新后重试"); } house.setRentingContractId(contractId); houseMapper.updateById(house); } }注意上面的compareAndSetStatus,这是乐观锁的典型应用。为什么会遇到并发问题?门店里两个业务员同时操作一套房源,A刚给客户签了合同,B这边也在走签约流程。如果没有乐观锁,B读了旧的"未出租"状态,签完合同覆盖回去,A的签约记录就被顶掉了。加上状态比较更新,第二次更新返回0条记录,直接提示业务员刷新重试,就避免了脏写。
4.2 合同到期提醒:定时任务与日期计算的坑
合同到期提醒是我觉得这套系统最"贴心"的功能。运营人员最怕的就是合同到期忘了处理,房子空了半个月才知道。提醒规则我设置了三档:
- 合同到期前30天,给运营发送站内信提醒,提示准备续约或发布房源
- 合同到期前7天,给租客发送短信提醒,确认是否续租
- 合同到期后3天,如果仍未续约且未退租,给运营发送"疑似违约"告警
定时任务用Spring自带的@Scheduled实现,每天凌晨2点执行一次:
@Component public class ContractRemindTask { @Scheduled(cron = "0 0 2 * * ?") public void remindExpiringContracts() { LocalDate today = LocalDate.now(); List<Contract> expiring30 = contractMapper.selectList( new LambdaQueryWrapper<Contract>() .eq(Contract::getStatus, ContractStatus.BEGINNING) .between(Contract::getEndDate, today.plusDays(30), today.plusDays(29))); // 生成提醒消息 } }这里有个非常容易踩的坑:查询"未来30天内到期"时,如果用between(endDate, today, today.plusDays(30)),会把今天已经过期的合同漏掉,或者把第31天才到期的合同算进来。我最后用的是"到期日 >= 今天 AND 到期日 <= 今天+30天"这种收敛写法,并且日期全部用LocalDate,避免Date类型时区错乱导致差一天的问题。
4.3 账单自动生成:把"月付、季付、年付"抽象成一种算法
账单生成逻辑写在合同生效的那一刻。比如一份合同是年付,默认一次性生成12条月度账单(或者按约定生成一条年度账单)。这个需求看起来简单,实际实现时有个很现实的问题:每个月的租金可能不同(比如第二年涨租),违约金、滞纳金的计算规则也五花八门。
我采用的方案是在合同里冗余存一份计费规则快照,账单生成时读取快照,而不是实时去读取房屋表的租金。为什么?因为房租半年后可能涨了,但已经生成的账单金额不能跟着变。如果每次查询账单时都去join房屋表拿最新租金,历史账目就会对不上。这一点对财务系统尤为重要——账单一旦生成,金额必须是"冻结"的。
public List<Bill> generateBills(Contract contract) { List<Bill> bills = new ArrayList<>(); LocalDate cursor = contract.getStartDate(); int cycleMonths = contract.getPaymentCycle(); // 1/3/6/12 while (cursor.isBefore(contract.getEndDate())) { Bill bill = new Bill(); bill.setContractId(contract.getId()); bill.setBillType(BillType.RENT); LocalDate periodEnd = cursor.plusMonths(cycleMonths).minusDays(1); if (periodEnd.isAfter(contract.getEndDate())) { periodEnd = contract.getEndDate(); } bill.setPeriodStart(cursor); bill.setPeriodEnd(periodEnd); bill.setAmount(contract.getMonthlyRent().multiply( BigDecimal.valueOf(cycleMonths))); bill.setDueDate(cursor.plusDays(7)); // 宽限期7天 bills.add(bill); cursor = periodEnd.plusDays(1); } return bills; }金额计算必须用BigDecimal,不能用double,这个是老生常谈但还是有人踩。99.9 * 3在浮点数里根本不是整数,做账单统计时会莫名多出几分钱。
逾期计算我单独封装成了接口,因为不同公司的滞纳金策略不一样:
public interface OverdueStrategy { BigDecimal calcOverdueFee(Bill bill, LocalDate settleDate); }默认实现是"逾期每天按应缴金额的0.5%计算",但逾期费有上限(不超过应缴金额的20%)。这里也是一个业务常见场景:如果不设上限,拖个半年滞纳金比本金还高,租客根本不会认。
5. 从开发到上线:我踩过的六个坑与排查链路
这部分我想专门拿出来说,因为整套系统从写得"能跑"到真正"敢给客户用",中间隔着半层地狱。我按时间线梳理一下实际部署和试运行阶段遇到的典型问题,每个问题都包含完整的排查思路,而不是直接甩结论。
5.1 时区问题:合同到期日期莫名其妙差一天
第一次部署到Linux服务器后,运营人员反馈说"7月1日到期的合同,系统6月30日就提醒了"。排查链路是这样的:先看服务器的系统时间,date命令显示正常;再看MySQL的时区,show variables like '%time_zone%',结果是SYSTEM,说明数据库用的是操作系统时区;接着看项目日志里打印的合同到期时间,发现Java代码里拿到的LocalDate是从new Date()转换来的,而服务器操作系统时区设置成了UTC。
问题根因清楚了:Java 8的LocalDate本身不带时区,但从Date转换时默认用系统时区。我在本地Windows开发环境是东八区,到了Linux服务器如果是UTC,日期转换就少了8小时。解决方案有两步:一是在数据库连接串上强制指定时区:
jdbc:mysql://localhost:3306/rent_db?serverTimezone=Asia/Shanghai二是在实体类接收日期参数时显式指定@JsonFormat(timezone = "GMT+8")。从那次以后,所有涉及日期时间的地方,我都统一用LocalDate/LocalDateTime,彻底不用java.util.Date,这个原则也写进了团队的开发规范。
5.2 并发租房的脏写问题:从超卖到乐观锁
系统上线第二周,运营反馈出现"同一套房两个人同时签了合同"。当时我第一反应是查日志,发现两个签约请求间隔只有200毫秒,都读到了房屋状态为"未出租"。代码里原本用的是普通的updateById,后一个请求把前一个签约关联的rentingContractId覆盖掉了。
修复方案就是前面提到过的compareAndSetStatus乐观锁。这里有个细节值得展开:乐观锁在单体应用下需要手动处理并发,在多实例部署时还要注意事务边界。我建议把"校验状态+更新状态"放在同一个事务方法里,并且让UPDATE语句本身带条件,而不是先查再改。
5.3 数据库字段与MySQL关键字冲突
建表时我给"合同备注"字段起了个名字叫comment,结果查询一直报语法错误。排查到最后才发现COMMENT是MySQL的保留关键字。这种坑很蠢但很常见,避免方法有两个:一个是建表时字段命名避开保留字列表;另一个是统一表字段风格,所有的说明类字段都叫remark或description,这样默认就避开了绝大多数关键字。
5.4 大事务导致的死锁与慢查询
账单自动生成功能有个隐患:如果合同是一次性生成12个月账单,事务里要插入12条记录,加上更新合同状态、更新房源状态,一个大事务跨了多张表,在高并发下容易出现死锁。排查时用的是MySQL的SHOW ENGINE INNODB STATUS,看到死锁集中在两张表上的两个UPDATE语句,锁的加锁顺序不一致。
解决办法是拆分事务:
@Transactional public void signContract(Contract contract, House house) { // 事务1:只操作合同表 contractMapper.insert(contract); // 事务2:只操作房屋表 houseStatusService.rentOut(house.getId(), contract.getId()); // 账单生成改为事务外异步执行,通过消息队列触发 billGenerateProducer.send(contract.getId()); }账单生成从主流程中剥离出去,就算生成失败也不影响签约主流程,重试机制兜底。
5.5 前后端联调的CORS与JWT过期问题
前后端分离部署时,前端地址是http://localhost:8081,后端接口是http://localhost:8080,跨域问题直接冒出来。配置了CORS过滤器后,又把OPTIONS预请求单独放行,否则拦截器会在预请求阶段就校验token,导致前端拿不到真实接口返回。
JWT过期也有个被忽略的点:分发token时设置2小时过期,但没做"续签"逻辑,用户用着用着突然登录失效,体验很差。后来加了一个滑动过期策略——只要用户在Token过期前有操作,就自动发放新Token,前端透明替换。
5.6 定时任务的多实例重复执行
上线时为了稳定性部署了两个实例,结果合同到期提醒短信每天发两条。排查后发现@Scheduled在每个实例上都会触发。解决方案不复杂,引入了一个基于MySQL行锁的分布式任务锁表,抢到锁的实例才执行任务。也可以用Redis的SETNX+过期时间实现,我最终选择了数据库锁的版本,因为这套系统没额外引入消息队列,MySQL锁对它来说开销最小。
6. 这套系统还能往哪扩展:从管理系统走向服务平台
现在回头看我写的这套代码,框架层其实没什么高深的技术,价值全在业务逻辑的组织方式和对各种边界情况的处理上。做完基础版本之后,我自己的体会是,这套系统完全可以沿着三个方向继续演进。
第一个方向是电子合同与支付对接。目前的合同还是线下签署后人工录入,账单也靠运营人员手动收款。下一步可以考虑接入电子签章服务,租客线上签署后合同自动生效;账单生成后对接微信支付/支付宝的即时到账,收据自动关联支付流水。这块的技术难点在于合同模板的字段映射和支付回调的对账逻辑,但数据基础我已经打好了。
第二个方向是房源智能匹配与数据分析。租赁业务积累了大量的历史成交数据,哪些户型好租、哪个小区平均空置期多少天、租金定价在什么区间最容易成交,这些都可以用数据报表呈现。我在系统里已经预留了租客偏好标签和房源标签,后面接个推荐接口就能做"猜你喜欢"。
第三个方向是物联网设备联动。现在一些品牌公寓已经给房门装了智能门锁,租客签约后自动下发临时密码,退租后立即失效。这套系统和门锁平台的对接并不复杂,关键在于把"合同生效/终止"这个事件通过消息通知出去。设计的时候把合同状态变更做成了事件发布模式,已经为这种集成留好了接口。
我个人实际使用中的体会是,做这类管理系统,最核心的能力不是会写几个框架,而是愿意去琢磨业务背后的规则。一个经验的房租租赁系统,背后是合同、账期、权限、状态机这些基础概念的组合。把这些基本功打扎实,换任何业务领域,你都能快速上手。