☰
Java房屋租赁管理系统源码:Spring Boot+MySQL实现房源合同账单
2026/10/23 12:27:37 网站建设 项目流程

简介:这份房屋租赁管理系统面向学习Java Web与传统SSH框架的在校学生和初中级开发者,提供一套可直接参考的租房业务实现,涵盖房源信息、用户管理与租赁记录等常见模块,适合课程设计、毕业设计或框架入门练习。压缩包共104个文件,约9.19MB,以jar依赖、java源码、class编译文件、jsp页面和xml配置为主,另含css样式、图片素材、tld标签库及数据库备份文件,能够支撑项目导入、环境配置与数据还原。已有6883人学习,说明其作为SSH整合案例具有一定参考价值。代码按Action、DAO、Form与实体类等方式分层,配合Struts与Hibernate配置,便于读者理解请求流转、持久化映射和JSP展示之间的关系,也能从中借鉴登录校验、房源查询与租借信息维护的实现思路,用于对照排错和二次改造。

1. 房屋租赁管理系统能干什么:一套 Java 单体把房源到收租的链路串起来

上个月帮朋友接了个公寓管理的小活儿,房东手里三十多间房,租金、押金、合同到期日全靠一张 Excel,月底对账要对一下午,还漏过一笔退租押金。这类需求真不需要微服务,一套 Java 单体后端加 MySQL 就足够吃下:房源录入、租客建档、合同签约、租金账单、到期提醒、维修报修。这份《完整版 房屋租赁管理系统(完整源码+数据库文件)》走的就是这条路子,源码和建表脚本放在一起,解压、导库、改配置就能跑。适合三类人:想拿来交课程设计的、想照着改一套自己用的、以及想看看一套业务闭环的 Java Web 项目到底怎么分层的。下面按「判断技术栈 → 跑起来 → 拆核心模块 → 避坑 → 二次开发」的顺序往下走。

2. 技术栈判断与目录结构:先看清 pom.xml 再动手改代码

拿到一个 .rar 源码包,第一件事不是双击打开 IDE 导项目,而是先解压看根目录。这一步能省掉后面半小时的瞎折腾——很多包是传统 SSM 的 war 工程,你按 Spring Boot 的方式去导,导入向导都走不通。判断依据其实就三条:根目录有没有pom.xml、有没有src/main/resources/application.yml、有没有WEB-INF/web.xml。有 yml 没有 web.xml,基本是 Spring Boot;反过来有 web.xml 且 pom 里写着packaging为 war,那就是传统 SSM 那一套。

2.1 依赖与运行环境:JDK、Tomcat、MySQL 该配哪一版

这类房屋租赁系统属于典型的业务管理系统,没有高并发场景,技术选型也偏保守。判断完形态之后,运行环境按下面这张表配基本不会翻车:

组件Spring Boot 版工程传统 SSM 版工程
JDK8(少数用 11)8
构建工具Maven 3.6+Maven 3.6+
Web 容器内置 Tomcat,无需外装外装 Tomcat 8/9
数据库MySQL 5.7 或 8.0同左
持久层MyBatis / MyBatis-PlusMyBatis
前端Thymeleaf 或原生 HTML + LayuiJSP + Layui

关键点在于别乱升版本。JDK 8 的工程你拿 JDK 17 去编译,光是javax换jakarta就能让你怀疑人生;MySQL 8.0 的驱动类名是com.mysql.cj.jdbc.Driver,5.x 是com.mysql.jdbc.Driver,连接串还得带上serverTimezone,不然启动直接报时区错误。

看依赖别只看有没有,要看得出版本约束。下面是一段典型的 pom 片段,我加上了判断注释:

<!-- 有 parent 且是 spring-boot-starter-parent,几乎可以确定是 Spring Boot 工程 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.x</version> </parent> <dependencies> <!-- Web 层:如果同时出现 spring-boot-starter-thymeleaf,说明页面走模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 持久层:MyBatis-Plus 自带 CRUD,比原生 MyBatis 少写很多 XML --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <!-- MySQL 驱动:8.x 版本必须配 cj 驱动类名和时区参数 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> </dependencies>

这段依赖传递出的信息很明确:后端是 MVC 三层,持久层走 ORM 或半 ORM,没有引入 Redis、消息队列、分布式组件。这意味着整套系统的状态全部压在 MySQL 一个点上,好处是部署简单、事务好写,坏处是所有并发控制在数据库层面做,后面讲合同签约时会重点说这件事。如果你的 pom 里出现的是spring-boot-starter-data-jpa而不是 MyBatis,那 SQL 都在实体类的注解上,找查询逻辑要去 entity 里翻,别在 mapper 目录里白找。

2.2 分包结构:controller、service、mapper、entity 各管什么

Spring Boot 版工程的目录结构大同小异,解压后认准src/main/java下面的包名。典型的树长这样:

house-rental/ ├── pom.xml ├── src/main/java/com/example/rental/ │ ├── RentalApplication.java # 启动类,main 方法在这 │ ├── controller/ # 接收 HTTP 请求,做参数校验和路由 │ │ ├── RoomController.java │ │ ├── TenantController.java │ │ ├── ContractController.java │ │ └── BillController.java │ ├── service/ # 业务规则都在这层,事务注解也在这 │ │ ├── impl/ │ │ └── RoomService.java │ ├── mapper/ # 数据访问接口,对应 XML 或注解 SQL │ ├── entity/ # 和数据库表一一对应的实体 │ ├── dto/ # 前端传参的接收对象,别和 entity 混用 │ ├── common/ # 统一返回体、异常、常量、工具类 │ └── config/ # 拦截器、跨域、MyBatis 配置 ├── src/main/resources/ │ ├── application.yml # 数据源、端口、上传路径 │ ├── mapper/ # MyBatis 的 XML 文件 │ └── static/ # 前端静态资源 └── sql/ └── rental.sql # 建库建表和初始数据

分包的意义在于改代码时知道往哪下手。举个最常见的场景:房东说房源列表要加一个「按朝向筛选」。你改的是RoomController的入参、RoomService的查询组装、RoomMapper.xml的 where 条件,再加前端一个下拉框——四个地方,一条线。如果这套代码把 SQL 全写在 Controller 里,那你就得在一堆请求处理方法里翻,这种写法在老项目里不少见,遇到也别惊讶,只是多花点时间。

这里有个血泪经验:entity 和 dto 一定要分清。entity 是数据库的镜像,字段和表列严格对应;dto 是前端传过来的东西,可能包含分页参数、查询条件、甚至几个字段的组合。很多源码包里图省事,Controller 直接收 entity,结果前端传个pageNum进去,MyBatis 一拼 SQL 就报「Unknown column 'pageNum'」。看代码时如果发现实体类里塞了pageNum、pageSize这种字段,基本可以判定是偷懒写法,二次开发时最好拆开。

2.3 数据库文件里那几张表:房间、租客、合同、账单的关联

sql目录下的脚本是这套系统能不能跑起来的命门。导库之前先通读一遍建表语句,搞清楚主外键关系,不然后面写联表查询会很难受。房屋租赁的核心表一般就这几张:

表名作用关键字段
room房源/房间房间号、面积、朝向、月租金、状态(空闲/已租/维修)
tenant租客档案姓名、手机号、证件号、紧急联系人
contract租赁合同房间ID、租客ID、起止日期、月租、押金、状态
bill租金账单合同ID、账期、应收金额、实收金额、缴费状态
repair报修工单房间ID、报修内容、处理状态
sys_user后台账号用户名、密码、角色

关系是一间房对多份合同(历史合同留档),一份合同对多期账单(按月生成),一个租客可以签多份合同。这里最容易踩的坑是合同表和房间表的状态耦合:合同生效时房间状态要改成「已租」,合同到期或退租时要改回「空闲」。如果源码里这一步是手写的、没有放在事务里,那中途失败就会出现「合同生效了但房间还是空闲」的脏数据——后面章节会专门讲怎么把它包进事务。

看建表脚本还有个细节:注意有没有create_time、update_time、is_deleted这类字段。有is_deleted说明用了逻辑删除,查询时框架会自动拼is_deleted = 0,你手动写 SQL 时别忘了加这个条件,否则会把已删数据查出来。

3. 从 .rar 到登录页:导入 SQL、改配置、启动的完整流程

3.1 建库与导数据:SQL 执行顺序和初始账号

解压之后先把sql目录里的脚本按顺序过一遍。很多包会拆成两个文件:一个建表(schema.sql或rental.sql),一个插初始数据(data.sql)。必须先建表再插数据,顺序错了会报「Table doesn't exist」。

执行方式用命令行最稳,图形化工具导大文件容易超时:

# 登录 MySQL,注意 -p 后面不要直接跟密码,回车后交互输入更安全 mysql -u root -p # 建库,字符集必须是 utf8mb4,不然租客姓名里的生僻字会变问号 CREATE DATABASE rental_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 退出后用 source 导入,注意路径用正斜杠 mysql -u root -p rental_db < /path/to/rental.sql

导入完先别急着启动,花一分钟验证三件事。第一,执行show tables;看表数量对不对,少一张就说明脚本被截断了,多半是文件编码问题。第二,查一下sys_user表里的初始账号,管理员用户名密码通常写在脚本注释里,或者就是admin/123456这种;如果密码字段是 MD5 串,说明登录走的是加密比对,你得找源码里的加密工具类算一下。第三,看room表有没有初始房源数据,没有的话登录进去首页是空的,不是 bug,是你得自己录。

提示:导入报Unknown character set: 'utf8mb4'说明 MySQL 版本太老,低于 5.5.3。这种老环境建议直接换 5.7,别在原环境上硬改字符集,后患无穷。

3.2 三处必改配置:数据源、端口、文件上传路径

配置文件是跑通的分水岭。不管源码写得多完整,数据源一定是作者本机的,你不改就连不上。Spring Boot 版看application.yml,SSM 版看jdbc.properties或spring-dao.xml。

server: port: 8080 # 端口被占用就改成 8081、8082 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/rental_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你的密码 # 这一行十有八九要改 servlet: multipart: max-file-size: 10MB # 房源图片上传上限,默认 1MB 常常不够 max-request-size: 10MB # 自定义配置:文件上传的物理路径,很多源码在这埋坑 file: upload-path: D:/rental/upload/ # Windows 用盘符,Linux 用 /data/rental/upload/

三个参数得说清楚。url里的serverTimezone=Asia/Shanghai不能省,MySQL 8 驱动不配这个会抛The server time zone value is unrecognized,这是新手最常见的启动失败原因之一。useSSL=false是本地开发关掉 SSL 握手,省得控制台刷一堆警告。upload-path是房源图片存盘的物理目录,这个目录必须真实存在且程序有写权限,Windows 下反斜杠要写成正斜杠或双反斜杠,否则路径解析会出错。

还有一处容易忽略:如果源码里用了 JSP(传统 SSM 常见),Spring Boot 需要额外配spring.mvc.view.prefix和suffix;如果用的是 Thymeleaf,页面必须放在resources/templates下,放错位置就是 404,白页一片,控制台却没有任何报错,这个玄学问题后面避坑章节还会提。

3.3 启动与打包:IDEA 直跑和 war 包丢 Tomcat

Spring Boot 版最省事,找到带@SpringBootApplication注解的启动类,右键 Run。控制台出现Started XxxApplication in x.xx seconds就算起来了,浏览器打开http://localhost:8080就能看到登录页。

传统 SSM 版麻烦一些,两种跑法。第一种是在 IDEA 里配 Tomcat Server,Deployment 里加 Artifact 选war exploded,Application context 设成/,不然访问路径会多一层项目名。第二种是命令行打包再丢容器:

# 跳过测试打包,很多源码包的测试类引用了不存在的资源,不跳过会失败 mvn clean package -DskipTests # 产物在 target 目录下,复制到 Tomcat 的 webapps cp target/rental.war /opt/tomcat/webapps/ # 启动 Tomcat,看日志有没有 SEVERE /opt/tomcat/bin/startup.sh && tail -f /opt/tomcat/logs/catalina.out

打包这一步有个细节值得注意:war 包的最终访问路径是http://localhost:8080/rental,不是根路径。如果你在配置文件里写死了静态资源的绝对路径(比如/static/css/app.css),部署到子路径下就会全部 404,页面裸奔。正确做法是用相对路径或者 JSTL 的${pageContext.request.contextPath}拼前缀。二次开发时如果打算部署到服务器子目录,这一条必须提前处理,否则上线当天就得回滚。

4. 核心模块拆解:房源分页、合同签约、账单生成三条链路

4.1 房源列表分页:Controller 到 Mapper 的完整链路

房源列表是进系统第一眼看到的东西,也是分页逻辑最集中的地方。这段链路走通了,其他列表页基本是复制粘贴改表名。先看 Controller:

@RestController @RequestMapping("/api/room") public class RoomController { @Autowired private RoomService roomService; /** * 房源分页查询 * @param pageNum 页码,从 1 开始,前端默认传 1 * @param pageSize 每页条数,前端下拉框一般给 10/20/50 * @param status 房间状态筛选,null 表示全部:0 空闲 1 已租 2 维修 */ @GetMapping("/page") public Result<PageInfo<Room>> page( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) Integer status, @RequestParam(required = false) String keyword) { // 页码兜底,防止前端传 0 或负数导致 limit 偏移量为负 if (pageNum == null || pageNum < 1) { pageNum = 1; } PageHelper.startPage(pageNum, pageSize); List<Room> list = roomService.listByCondition(status, keyword); return Result.ok(new PageInfo<>(list)); } }

这段代码的关键在PageHelper.startPage()这一行。它把分页参数挂到当前线程的 ThreadLocal 上,紧接着的第一条 MyBatis 查询会被自动改写,追加limit和count语句。所以顺序不能乱:startPage必须紧挨着查询方法,中间插一句别的查询,分页参数就被那次查询消费掉了,结果是你查出来的是全表数据,分页失效,而且不报错——这是 PageHelper 最经典的坑。

Service 层负责条件组装,Mapper 层负责 SQL。看 XML 里的写法:

<select id="listByCondition" resultType="com.example.rental.entity.Room"> select id, room_no, area, orientation, rent_price, status, cover_img from room where is_deleted = 0 <!-- status 为 null 时不拼这个条件,实现"全部"筛选 --> <if test="status != null"> and status = #{status} </if> <!-- 关键字同时匹配房间号和楼栋名,前端只给一个输入框 --> <if test="keyword != null and keyword != ''"> and (room_no like concat('%', #{keyword}, '%') or building_name like concat('%', #{keyword}, '%')) </if> order by id desc </select>

参数说明:#{status}是预编译占位符,能防 SQL 注入;如果用${status},字符串拼接会把用户输入直接塞进 SQL,这是安全红线,源码里看到${}就要警惕。is_deleted = 0是逻辑删除条件,别漏。order by id desc让新录入的房源排前面,符合运营习惯。整个查询走的是room表的索引,数据量在几千条以内性能无感,真到几十万条再考虑给status和building_name建联合索引。

4.2 合同签约:一个 @Transactional 里要做完的四件事

签约是整个系统里业务密度最高的地方,也是最容易写出脏数据的地方。一次签约至少要完成四个动作:锁定房间、写合同记录、收押金和首期租金、生成后续账单。这四件事必须原子完成,中间任何一步失败都得整体回滚。

先看容易写错的版本——很多源码包会写成这样:先select查房间状态,判断是空闲,再update改成已租。这个写法在单机低并发下没问题,但只要两个人同时点签约,都查到「空闲」,都去更新,就出现了同一间房两份有效合同。这是典型的检查后执行竞态。

稳妥的写法是用带条件的更新,让数据库来保证原子性:

@Service public class ContractServiceImpl implements ContractService { @Autowired private RoomMapper roomMapper; @Autowired private ContractMapper contractMapper; @Autowired private BillMapper billMapper; /** * 签约 * @param dto 包含 roomId、tenantId、startDate、endDate、monthlyRent、deposit * @return 新合同 ID */ @Override @Transactional(rollbackFor = Exception.class) public Long sign(SignDTO dto) { // 1. 原子锁房:只有当前状态是 0(空闲)才更新成 1(已租) // 返回受影响行数,0 表示被别人抢先了 int locked = roomMapper.lockIfFree(dto.getRoomId()); if (locked == 0) { throw new BizException("该房间已被租出,请刷新后重试"); } // 2. 写合同主记录,状态置为生效中 Contract contract = new Contract(); contract.setRoomId(dto.getRoomId()); contract.setTenantId(dto.getTenantId()); contract.setStartDate(dto.getStartDate()); contract.setEndDate(dto.getEndDate()); contract.setMonthlyRent(dto.getMonthlyRent()); contract.setDeposit(dto.getDeposit()); contract.setStatus(1); contractMapper.insert(contract); // 3. 生成首期账单:押金 + 第一个月租金,账期取合同起始月 Bill first = new Bill(); first.setContractId(contract.getId()); first.setPeriod(DateUtil.format(dto.getStartDate(), "yyyy-MM")); first.setAmount(dto.getMonthlyRent().add(dto.getDeposit())); first.setStatus(0); billMapper.insert(first); return contract.getId(); } }

对应的 Mapper 方法,SQL 是重点:

<!-- 条件更新:status = 0 才更新,这一步由数据库行锁保证互斥 --> <update id="lockIfFree"> update room set status = 1, update_time = now() where id = #{roomId} and status = 0 and is_deleted = 0 </update>

参数说明:rollbackFor = Exception.class必须显式写,Spring 默认只对RuntimeException回滚,业务里抛的受检异常不会触发回滚,这是另一个常见事故点。lockIfFree的 where 条件里带上status = 0,更新时 InnoDB 会对这一行加写锁,两个并发请求只有一个能更新成功,另一个拿到 0 行受影响,直接抛业务异常让用户重试,比加分布式锁轻量得多。

还有一件事源码里经常漏:合同的起止日期要做校验,结束日期必须大于开始日期,租期至少一个月。这种校验放在 Service 开头,别指望前端,接口是可以被直接调的。

4.3 租金账单生成:定时任务与逾期状态流转

账单是这个系统真正产生价值的地方。房东最关心的不是合同长什么样,而是「这个月谁还没交钱」。账单一般有两种生成方式:签约时一次性把整个租期的账单全部生成,或者用定时任务每月初生成当月账单。前者简单,后者更符合实际,因为租金可能调整、合同可能提前终止。

源码里如果有@Scheduled注解,说明走的是定时任务:

@Component public class BillScheduleTask { @Autowired private ContractMapper contractMapper; @Autowired private BillMapper billMapper; /** * 每月 1 号凌晨 2 点生成当月账单 * cron 六位:秒 分 时 日 月 周 * 凌晨跑是为了避开白天业务高峰,出问题也有一整天时间处理 */ @Scheduled(cron = "0 0 2 1 * ?") public void generateMonthlyBill() { String period = DateUtil.format(new Date(), "yyyy-MM"); // 只处理生效中的合同,已到期和已退租的跳过 List<Contract> activeList = contractMapper.selectActiveContracts(); for (Contract c : activeList) { // 幂等保护:同一合同同一账期只生成一条,防止任务重跑产生重复账单 int exist = billMapper.countByContractAndPeriod(c.getId(), period); if (exist > 0) { continue; } Bill bill = new Bill(); bill.setContractId(c.getId()); bill.setPeriod(period); bill.setAmount(c.getMonthlyRent()); bill.setStatus(0); // 0 待缴 1 已缴 2 逾期 billMapper.insert(bill); } } }

这段代码值得说的有两点。第一是幂等,定时任务最怕重跑,服务器重启、手工触发、集群多节点都会导致重复执行,加一条countByContractAndPeriod判断,成本极低但能省掉大量对账麻烦。第二是cron表达式,0 0 2 1 * ?表示每月 1 号 2 点,如果写成0 0 2 * * ?就变成每天跑一次,虽然幂等判断能兜住,但会白跑很多次。

逾期状态流转是另一条链路。常见做法是再挂一个每日任务,把due_date已过且status = 0的账单刷成2:

<!-- 批量刷逾期,一条 SQL 搞定,别在 Java 里 for 循环逐条 update --> <update id="markOverdue"> update bill set status = 2, update_time = now() where status = 0 and due_date &lt; curdate() and is_deleted = 0 </update>

参数说明:&lt;是 XML 里小于号的转义写法,直接写<会让 XML 解析失败,这是写 MyBatis XML 时最常犯的语法错误。用一条批量 update 替代循环,几千条数据也是毫秒级,循环逐条更新在数据量上来后会把连接池打满。

5. 跑不起来的排查清单:五处最容易翻车的地方

5.1 启动阶段:连不上库、端口占用、依赖拉不下来

现象一:启动报Communications link failure或Access denied for user。原因基本是两个:一是application.yml里的密码还是作者的,没改成你本机的;二是 MySQL 8 的驱动类名和时区参数没配对。解决方式很直接,先把 url 里的serverTimezone=Asia/Shanghai补上,驱动类改成com.mysql.cj.jdbc.Driver,再确认账号密码。如果还连不上,用命令行mysql -u root -p试一下,命令行都连不上说明是服务没启动或者端口不是 3306。

现象二:启动报Port 8080 was already in use。原因是你本机已经有别的服务占了 8080,很可能是上一次没关干净的进程。Windows 下netstat -ano | findstr 8080找到 PID 再taskkill /F /PID xxx;Linux 下lsof -i:8080或ss -lntp | grep 8080。更省事的办法是直接把server.port改成 8081,改配置比杀进程快。

现象三:Maven 依赖一直下载失败,卡在Downloading from central。原因是网络到中央仓库不稳定,或者 pom 里引用了已经下架的旧版本。解决办法是在settings.xml里配一个国内镜像源,然后删掉本地仓库里对应的.lastUpdated文件重新拉。如果某个依赖实在拉不下来,看 pom 里它的版本号是不是写了个不存在的号,这种多半是作者打包时手动改过版本忘了改回去。

5.2 运行阶段:中文乱码、时间差 8 小时、上传图片 404

现象四:页面能打开,但所有中文显示成问号或方块。原因链条有三段,得逐段排查:数据库建库时字符集不是utf8mb4、JDBC 连接串没带characterEncoding=utf8、前端页面 meta 里没声明 UTF-8。三段任意一段断了都会乱码。最快的定位方式是直接在数据库里select一条中文数据,如果数据库里就是问号,那是导入阶段的问题,重新用--default-character-set=utf8mb4导一次;如果数据库里正常、页面上乱码,那就是连接串或页面编码的问题。

现象五:账单的创建时间比实际时间少 8 小时。这个是时区配置的经典表现。MySQL 服务端时区是 UTC,Java 应用按东八区解析,两边差 8 小时。三个地方要统一:JDBC url 里的serverTimezone=Asia/Shanghai、Spring Boot 的spring.jackson.time-zone=GMT+8、以及 MySQL 服务端的default-time-zone。三处都对齐了,时间才不会飘。这个坑最阴的地方在于它不影响功能,只影响数据,等发现的时候历史数据已经错了一大片,后悔药都没得吃。

现象六:房源图片上传成功,但页面上是裂图,控制台报 404。原因是上传目录和静态资源映射目录不是同一个。文件存到了D:/rental/upload/,但 Spring Boot 默认只把resources/static暴露成静态资源,磁盘上的外部目录访问不到。解决方式是加一个 WebMvc 配置类,把物理路径映射成 URL 路径:

@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 浏览器访问 /upload/xxx.jpg 时,去磁盘的 uploadPath 目录找文件 // 结尾的斜杠不能少,否则路径拼接会缺分隔符 registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }

参数说明:addResourceLocations的前缀file:表示本地文件系统,少了它就变成类路径查找,照样 404。uploadPath结尾必须带斜杠,这个细节不写对,路径会拼成D:/rental/uploadxxx.jpg,报错信息还特别隐晦。

注意:这套系统里最容易出问题的不是业务代码,是环境和配置。跑不起来的时候,先看控制台第一段异常,别被后面几十行的堆栈带偏,真正的根因永远在第一行Caused by。

6. 二次开发:加一个退租结算,把改造边界划清楚

退租结算是我认为最值得拿来练手的功能,因为它横跨合同、房间、账单三张表,能完整走一遍「改一处、动三处」的链路,做完这一遍,这套系统的骨架你就摸透了。

需求拆开其实就四步:合同状态置为已终止、房间状态改回空闲、结算押金(扣除欠费和水电)、把未出账的账单作废。四步必须在一个事务里,逻辑和签约是对称的。

@Override @Transactional(rollbackFor = Exception.class) public BigDecimal checkout(Long contractId, BigDecimal deductAmount) { Contract c = contractMapper.selectById(contractId); if (c == null || c.getStatus() != 1) { throw new BizException("合同不存在或已终止"); } // 1. 查该合同下所有未缴账单,欠费从押金里扣 BigDecimal unpaid = billMapper.sumUnpaidByContract(contractId); // 2. 实退押金 = 押金 - 欠费 - 其他扣款,负数按 0 处理,不倒找钱 BigDecimal refund = c.getDeposit().subtract(unpaid) .subtract(deductAmount == null ? BigDecimal.ZERO : deductAmount); if (refund.compareTo(BigDecimal.ZERO) < 0) { refund = BigDecimal.ZERO; } // 3. 合同终止、房间释放,两个 update 都带状态条件防重复提交 contractMapper.terminate(contractId); roomMapper.release(c.getRoomId()); // 4. 未缴账单标记为作废,保留记录便于对账,不做物理删除 billMapper.voidUnpaidByContract(contractId); return refund; }

金额计算这块有讲究。全程用BigDecimal,绝对不能用double,0.1 + 0.2 != 0.3这种浮点误差在财务场景是会出事故的。compareTo而不是equals,因为BigDecimal("1.0")和BigDecimal("1.00")用equals比是 false,compareTo才是比数值。这两个细节我在别的项目里见过真实翻车,对账时差了三分钱,查了一整晚。

改造时还要注意边界:别去动bill表的历史数据,改成逻辑作废而不是delete,房东年底要对账,删了就没法追溯。另外,退租之后这套系统里通常还要留一个「退租记录」表或者给合同加个terminate_date字段,不然半年后房东问「这间房上一个租客什么时候走的」,你只能去翻日志。

从那以后我每次动这类涉及金额和状态的模块,都会先把「失败会留下什么脏数据」写在纸上,再决定事务边界画在哪。这套源码的骨架不复杂,真正花时间的是这些边界条件,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询