基于Java Spring Boot的房屋租赁系统:从数据库设计到状态机实践
简介基于Java的房屋租赁系统设计与实现是一份完整的毕业设计文档面向计算机相关专业学生及需要开发租赁管理系统的开发者围绕传统租赁行业信息化程度低、人工操作效率不高等问题给出从需求分析、系统设计到编码实现的完整方案。文档采用Java、JSP、MySQL及B/S架构先从研究背景与开发目标切入再展开开发环境介绍、系统可行性分析、项目设计原则、系统流程分析继而细化到体系结构、数据库实体与表设计并详述管理员登录验证、管理员功能模块、用户界面设计等核心实现最后涵盖系统测试方法与结论。内容章节完整目录结构清晰可作为课程设计或毕业论文撰写的参考模板。包内共1个docx文件压缩包大小约3.86MB整体体积小巧便于快速阅读与参照整理。目前已有51人学习下载适合希望理解房屋租赁系统整体开发流程及论文写作规范的读者。1. 基于 Java 的房屋租赁系统毕设经典选题为什么落地时总卡在状态一致性上房屋租赁系统是 Java 课程设计和毕业设计里出现频率最高的课题之一表面看功能清单很清晰房源发布、租客管理、在线签约、账单催缴。真正动手做的人会发现页面和增删改查只是前 20% 的工作量剩下 80% 的精力全耗在三件事上合同签订时怎么保证房源不被重复下单账单金额怎么算得一分不差租客权限怎么做到只能看自己的数据。这三件事做不好系统演示时所有页面都能打开一进入真实业务场景就翻车。这篇笔记面向两类人一类是把“基于 Java 的房屋租赁系统”当成毕设或课设题目、需要一份完整可复现方案的在校生另一类是刚工作不久、想用这个业务练手 Spring Boot 项目的初级 Java 工程师。标题里的“设计与实现”意味着交付物不止是能跑起来的项目还有一套能讲清楚的设计文档。下面按从需求拆解到数据库设计、再到核心代码实现和排错的顺序把整条路径完整走一遍。2. 先把需求边界划清楚房屋租赁系统到底要管哪些事2.1 功能边界别把系统设计成“房源管理 租客管理”两个模块的拼盘很多现成课设代码把房屋租赁系统简化为两张表一张放房源一张放租客最多再加一个合同表记录“谁租了哪套房”。这种设计用来应付演示没问题但答辩时老师问一句“退租时押金怎么退、逾期账单怎么算、续租怎么处理”整个方案的漏洞就全暴露出来了。一个能自圆其说的房屋租赁系统核心业务应该围着合同生命周期展开。房源是标的物租客是签约主体合同是核心单据账单和缴费记录是合同执行过程中产生的流水。功能上至少要拆出这几个域房源管理发布、下架、维护状态、租客管理实名信息、历史租约、合同管理新建、续租、退租、终止、账单管理租金生成、缴费登记、逾期标记、系统管理后台账号、角色权限。看房预约这类功能属于加分项如果时间紧张可以放到二期不影响主流程闭环。建议后台管理用网页端租客侧尽量简化这个度拿捏好工作量就能控制在一个人能完成的范围内。2.2 技术选型Spring Boot MyBatis Plus MySQL 为什么是默认答案技术栈没必要标新立异。做毕设写文档第一原则是选自己讲得清楚的技术做工程练手第一原则是选市场上用得多的技术。这两个条件同时满足的组合就是 Spring Boot MyBatis Plus MySQL前端用 Thymeleaf 或 Vue 都行权限用 Sa-Token 或 Shiro 都行没必要在这层过度纠结。Spring Boot 解决的是配置地狱问题起步依赖一拉、application.yml 一写项目就能跑起来。MyBatis Plus 的核心价值不只是 CRUD它的条件构造器能让动态查询 SQL 写起来很舒服更重要的是它支持根据实体类注解生成建表语句这个特性在开发初期改表结构时非常省事在 idea 里把实体类字段加一行注解运行一段测试代码就能输出对应的 DDL赶工阶段能少写大量手写 SQL。对比项SSHStruts Spring HibernateSSMSpring Spring MVC MyBatisSpring Boot MyBatis Plus配置成本高XML 一堆中等极低学习曲线旧体系资料过时能学到 SQL 控制力能学到规范工程实践答辩友好度容易背上“还在用老技术”的质疑可接受最稳开发效率低中等高2.3 工程结构分包方式决定了代码在答辩现场撑不撑得住工程结构建议按 mvc 分层分包但要在标准三层之上加一层清晰的业务边界。常见做法是controller只做参数接收和结果封装service放业务规则mapper只做数据访问entity对应数据库表dto和vo分别管入参和出参。这个分包方式在答辩时最经得起追问老师问“为什么 service 里不直接写 SQL”回答“为了隔离数据访问细节、方便单测”就能过关。要注意的是实体类不要直接当返回对象用。比如房源实体里有status字段租客端看到的应该是“可预约”“已出租”这种文案而不是数字 0、1、2。这个转换逻辑放在 service 层做vo 里只带展示字段。踩过这个坑的人都懂实体类直出接口前端拿到的永远是一堆裸数字后端改一个字段名前端就要跟着改一轮。3. 数据库表设计把房屋租赁业务翻译成 MySQL 里的十张核心表3.1 房源表、租客表与合同主表核心字段怎么设计才不返工房屋租赁系统的数据模型说复杂不复杂说简单也不简单。房源表、租客表、合同表这三张是地基地基没打稳后面全是返工。房源表至少要包含房源编号、标题、户型、面积、租金月付金额、押金规则、地址信息、状态字段、归属管理员 ID。租金金额必须用 DECIMAL房租这种固定金额如果用了 float 或者 double后续账单合计会出精度问题这一条是硬经验。租客表要区分“自然人和潜在租客”两个概念。来看过房但没签合同的只需要手机号和姓名签了合同的必须补身份证号、紧急联系人、职业信息等。这两类数据如果混在一张表里字段会大量留空而且“是否实名认证”这个状态没法表达清楚。常见设计是租客表加一个auth_status字段0 表示未实名1 表示已实名签合同前强制要求租客完成实名认证这一步。合同主表是整张库的核心。合同号、房源 ID、租客 ID、起租日期、结束日期、月租金、押金、合同状态、签订时间、备注这些字段一个都不能少。合同状态至少要有生效中、已到期、已退租、已终止四种。这里给一个合同表的建表语句示例字段类型和默认值都是按实际项目打磨过的方案。CREATE TABLE contract ( id bigint NOT NULL 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 NOT NULL COMMENT 起租日期, end_date date NOT NULL COMMENT 结束日期, monthly_rent decimal(10,2) NOT NULL COMMENT 月租金, deposit decimal(10,2) NOT NULL COMMENT 押金, status tinyint NOT NULL DEFAULT 1 COMMENT 1生效中 2已到期 3已退租 4已终止, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_contract_no (contract_no), KEY idx_house_id (house_id), KEY idx_tenant_id (tenant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT房屋租赁合同表;合同号要加唯一索引这个字段建议格式化成类似HT20240101001这种可读编号。start_date和end_date用 date 类型而不是 varchar后面做日期计算时可以直接用 MySQL 的日期函数省去一堆字符串解析代码。monthly_rent和deposit都定义成decimal(10,2)10 位有效数字足够覆盖绝大多数房租金额2 位小数对应分的精度。3.2 账单表与费用计算金额字段为什么不能存成 double合同签完之后每个月都要产生一笔租金账单。账单表里要存关联的合同 ID、账单月份、应收金额、实收金额、缴费状态、缴费时间。这里最容易犯的一个错误是把“账单月份”设计成字符串2024-01然后每次查询都 LIKE 匹配正确做法是拆成year和month两个 int 字段或者直接用 date 类型指向当月的 1 号。拆字段之后按月统计和按年统计都能走索引效率不是同一个量级。账单生成逻辑的核心是“本月应缴租金 月租金 ÷ 当月天数 × 实际居住天数”。这个公式有两个坑第一个坑是除法产生无限小数第二个坑是“当月天数”到底按自然月算还是按计费周期算。业界常见做法是统一按自然月计算起租当天算第一天退租当天不算租金也就是半开区间。计算公式里所有除法都必须用 BigDecimal 的divide方法并指定精度否则系统跑几个月后对账会发现差了几分钱这种问题最难排查。CREATE TABLE bill ( id bigint NOT NULL AUTO_INCREMENT, bill_no varchar(32) NOT NULL COMMENT 账单编号, contract_id bigint NOT NULL COMMENT 合同ID, bill_year int NOT NULL COMMENT 账单年份, bill_month int NOT NULL COMMENT 账单月份, receivable decimal(10,2) NOT NULL COMMENT 应收金额, received decimal(10,2) DEFAULT 0.00 COMMENT 实收金额, status tinyint NOT NULL DEFAULT 0 COMMENT 0待缴费 1已结清 2已逾期 3已减免, pay_time datetime DEFAULT NULL COMMENT 缴费时间, PRIMARY KEY (id), UNIQUE KEY uk_bill_no (bill_no), UNIQUE KEY uk_contract_month (contract_id, bill_year, bill_month), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT租金账单表;3.3 状态机字段房源状态从“待出租”到“已入住”的流转控制房屋租赁系统里最容易做乱的是房源状态。一套房源从录入系统到退租清退至少经历如下状态空置待租、已预定、已出租、维修中、已下架。不少实现用status字段存一个数字然后在 service 里到处if status 1改状态改到最后状态值越用越乱甚至出现“已出租的房源还能被预约看房”这种低级 bug。正确做法是给状态流转画一张明确的状态机图然后用常量类或枚举把所有合法流转路径定义好。这里选用枚举实现因为枚举既能做类型约束又能把流转规则收拢到一个类里维护后续新增状态只需要改一处。public enum HouseStatus { AVAILABLE(0, 待出租), RESERVED(1, 已预定), RENTED(2, 已出租), REPAIRING(3, 维修中), OFF_SHELF(4, 已下架); private final int code; private final String desc; HouseStatus(int code, String desc) { this.code code; this.desc desc; } /** * 校验合法流转路径 * from - 当前状态 * to - 目标状态 * 非法流转抛出异常调用方捕获后返回业务错误 */ public static boolean canTransform(int from, int to) { // 待出租可以流转到已预定、维修中、已下架 if (from AVAILABLE.code) { return to RESERVED.code || to REPAIRING.code || to OFF_SHELF.code; } // 已预定可以流转到已出租签约成功、待出租预定取消 if (from RESERVED.code) { return to RENTED.code || to AVAILABLE.code; } // 已出租只能流转到待出租退租完成 if (from RENTED.code) { return to AVAILABLE.code; } // 维修中只能回到待出租 if (from REPAIRING.code) { return to AVAILABLE.code; } // 下架后只能重新上架 return from OFF_SHELF.code to AVAILABLE.code; } public static boolean isValid(int code) { for (HouseStatus status : values()) { if (status.code code) { return true; } } return false; } }流转校验放在 service 层统一入口里调用。所有更新状态的操作都必须先调canTransform校验校验不通过直接抛业务异常。这个设计能拦截绝大多数非法流转比如把维修中的房源直接置为已出租。isValid方法用于接收外部参数时校验传入的状态值是否合法防止接口层直接把非法数字透传到数据库。4. 核心功能实现房源发布、租约签订与账单生成4.1 房源发布与状态流转最小可运行的服务层代码房源发布不只是往数据库插入一条记录还包含业务校验和状态初始化。一个合格的新增房源接口要做三件事检查当前操作人是否有权限、校验必填参数和租金金额合理性、初始化房源状态为空置待租。下面给出一个 service 层的典型实现剥离了 controller 层的参数转换只留核心业务。Service public class HouseService { Autowired private HouseMapper houseMapper; Transactional(rollbackFor Exception.class) public House addHouse(HouseAddDTO dto, Long operatorId) { // 1. 参数校验月租金必须大于0面积必须大于0 if (dto.getMonthlyRent() null || dto.getMonthlyRent().compareTo(BigDecimal.ZERO) 0) { throw new BizException(月租金必须大于0); } if (dto.getArea() null || dto.getArea() 0) { throw new BizException(面积必须大于0); } // 2. 状态初始化新录入房源统一置为待出租 House house new House(); house.setTitle(dto.getTitle()); house.setAddress(dto.getAddress()); house.setArea(dto.getArea()); house.setMonthlyRent(dto.getMonthlyRent()); house.setDeposit(dto.getDeposit()); house.setStatus(HouseStatus.AVAILABLE.getCode()); house.setOwnerId(operatorId); house.setCreateTime(LocalDateTime.now()); // 3. 执行插入 houseMapper.insert(house); return house; } }这段代码里有几个容易被忽略的细节。rollbackFor Exception.class让所有异常都触发回滚避免出现“房源插入成功但状态流转记录没写进去”这种数据不一致。金额比较统一用compareTo(BigDecimal.ZERO) 0绝对不能改成dto.getMonthlyRent() 0前一种是 BigDecimal 的正确比较姿势后一种直接编译报错金额更不能用于等值比较关于这一点后面避坑章节会展开。status初始化不是直接写数字 0而是从枚举里取语义明确的常量值如果枚举的 code 调整了业务代码不用跟着改。4.2 租约签订创建合同时要同时锁住房源租约签订是整个系统里并发风险最高的操作。两个租客同时看中同一套房后台同时提交签约如果代码只是先查房源状态再插入合同两个请求都会读到“待出租”结果就是同一套房签出两份合同。这个问题必须在事务里用行锁解决。实现分两步走第一步用SELECT ... FOR UPDATE锁定房源行第二步在锁内再次校验状态并创建合同、更新房源状态。MySQL 的 InnoDB 引擎在查询命中主键索引时会锁住对应行同一个房源 ID 上的第二个事务会阻塞到第一个事务提交后才能继续。Transactional(rollbackFor Exception.class) public Contract signContract(SignContractDTO dto) { // 1. 锁定房源行防止并发重复签约 House house houseMapper.selectByIdForUpdate(dto.getHouseId()); if (house null) { throw new BizException(房源不存在); } // 2. 在锁内再次校验状态必须为待出租 if (house.getStatus() ! HouseStatus.AVAILABLE.getCode()) { throw new BizException(房源当前状态不可签约); } // 3. 校验租客实名状态未实名不允许签约 Tenant tenant tenantMapper.selectById(dto.getTenantId()); if (tenant null || tenant.getAuthStatus() ! 1) { throw new BizException(租客未完成实名认证); } // 4. 生成合同编号并插入合同 String contractNo generateContractNo(); Contract contract new Contract(); contract.setContractNo(contractNo); contract.setHouseId(house.getId()); contract.setTenantId(tenant.getId()); contract.setStartDate(dto.getStartDate()); contract.setEndDate(dto.getEndDate()); contract.setMonthlyRent(house.getMonthlyRent()); contract.setDeposit(house.getDeposit()); contract.setStatus(ContractStatus.ACTIVE.getCode()); contractMapper.insert(contract); // 5. 房源状态流转待出租 - 已预定 - 已出租 house.setStatus(HouseStatus.RENTED.getCode()); houseMapper.updateById(house); return contract; }selectByIdForUpdate是自定义的 mapper 方法XML 里对应SELECT * FROM house WHERE id #{id} FOR UPDATE。这里要注意锁的粒度只用房源 ID 做锁定条件避免锁住整张表。创建合同和更新房源状态必须在同一个事务里完成缺一个就会造成合同存在但房源仍是待出租的脏状态。合同编号生成建议用时间戳加随机数再加房源 ID 后四位拼接避免高并发下重复。4.3 账单生成与退租结算按月分摊和押金退还的核心算法合同生效后账单系统按月自动生成租金账单。生成逻辑不是简单地把月租金逐月复制而是要处理起租不足月、退租不足月这两种情况。下面这段代码实现了按月分割账单的算法基于合同起止日期一次性生成整份租约的全部账单。public ListBill generateBills(Long contractId) { Contract contract contractMapper.selectById(contractId); LocalDate start contract.getStartDate(); LocalDate end contract.getEndDate(); ListBill billList new ArrayList(); // 从起租月开遍历到结束月截止 LocalDate cursor start.withDayOfMonth(1); while (!cursor.isAfter(end.withDayOfMonth(1))) { int year cursor.getYear(); int month cursor.getMonthValue(); // 当月应缴 月租金 ÷ 当月总天数 × 当月实际居住天数 BigDecimal daysInMonth BigDecimal.valueOf(cursor.lengthOfMonth()); BigDecimal livedDays; if (cursor.getYear() start.getYear() cursor.getMonthValue() start.getMonthValue()) { // 起租当月从起租日算到月底含起租日天数 当月末日 - 起租日 1 livedDays BigDecimal.valueOf(start.lengthOfMonth() - start.getDayOfMonth() 1); } else if (cursor.getYear() end.getYear() cursor.getMonthValue() end.getMonthValue()) { // 结束当月从月初算到结束日不含结束日天数 结束日 - 1 livedDays BigDecimal.valueOf(end.getDayOfMonth() - 1); } else { // 整月按整月天数计算 livedDays daysInMonth; } BigDecimal receivable contract.getMonthlyRent() .divide(daysInMonth, 2, RoundingMode.HALF_UP) .multiply(livedDays) .setScale(2, RoundingMode.HALF_UP); // 构造账单记录并插入 Bill bill new Bill(); bill.setBillNo(generateBillNo(contract.getContractNo(), year, month)); bill.setContractId(contract.getId()); bill.setBillYear(year); bill.setBillMonth(month); bill.setReceivable(receivable); bill.setStatus(BillStatus.UNPAID.getCode()); billList.add(bill); cursor cursor.plusMonths(1); } billService.saveBatch(billList); return billList; }这个算法里最容易出错的是边界日期的计算。起租当月按“当月总天数减起租日加一”算居住天数比如 1 月 15 日起租1 月租期就是 31 减 15 加 1 等于 17 天。结束当月按“结束日减一”算居住天数比如 4 月 14 日退租4 月租期就是 13 天因为 14 日当天不算租金这个约定会在说明文档里写清楚避免租客对账时扯皮。所有除法都用divide(daysInMonth, 2, RoundingMode.HALF_UP)指定了 2 位小数和四舍五入不会出现除不尽导致的金额漂移。5. 避坑清单房屋租赁系统最常见的 5 个翻车点5.1 金额精度double 和 BigDecimal 的血泪差距现象账单列表里每月租金显示 3500.00但年度合计变成 41999.99999999999对账时怎么都对不上。原因代码里用了 double 做金额计算浮点数在二进制里无法精确表达 0.1多次加减乘除后误差被放大。解决数据库字段统一用decimal(10,2)Java 实体属性统一用BigDecimal所有运算都用 BigDecimal 的方法完成禁止在 service 层出现double price这种变量声明。我的习惯是在代码审查时直接全局搜索double和float出现在金额相关类里的情况发现一个改一个这个习惯能替后期省掉大量对账排查时间。5.2 并发签约同一间房被两个租客同时下单现象项目部署到测试环境用两个浏览器同时提交同一间房的签约请求数据库里出现两份生效合同。原因service 方法没有加锁两个事务同时读到房源状态为“待出租”都把校验通过后插入了合同。解决签约方法加Transactional第一步用SELECT ... FOR UPDATE锁住房源行让第二个事务阻塞到第一个事务提交后再执行这样第二个事务读到的就是“已出租”状态走到校验分支直接抛出异常。5.3 合同日期边界退租当天到底算不算租金现象租客 2024 年 12 月 31 日到期系统生成的 12 月账单把这个月整月都算了钱租客投诉多收一天房租。原因账单生成逻辑把结束日期当天也计入租期而租客通常当天就搬走不会再住一晚。解决统一约定为开区间计租起租日算钱、退租日不算钱账单算法里结束月用end.getDayOfMonth() - 1计算居住天数并且在设计文档里把这个约定写清楚。日期边界这类问题不只是一个计算错误它是整个系统的业务约定合同、账单、违约金的计算全都要遵循同一套口径。5.4 房源状态不同步后台已下架小程序还能约看房现象运营人员在后台把一套维修中的房源点了下架但小程序端仍然展示“可预约”用户提交看房申请竟然成功了。原因小程序端查询房源列表时只过滤了status ! 下线状态这一个条件漏掉了维修中状态而维修中的房源在后台被置为下架状态时没有同步清理已生成的看房排期。解决看房预约的查询和插入统一走一个带状态校验的服务方法这个方法对房源状态做完整合法性检查凡是可预约的房源必须同时满足“不是维修中”且“未下架”。状态机枚举在这里帮了大忙所有状态变更都走统一入口就不存在多端过滤条件不一致的问题。5.5 租客数据越权登录用户能查到别人的身份证照片现象租客 A 登录小程序后把请求里的合同 ID 改成租客 B 的合同 ID接口竟然返回了 B 的身份证照片 URL。原因后端接口只校验了“是否已登录”没有校验“这条数据是否属于当前登录用户”。解决在服务层加数据归属校验所有查询合同的接口都必须传入当前登录租客 IDSQL 查询条件中强制带上tenant_id 当前登录用户后端拿不到归属权就不返回数据。合同和账单这类敏感数据一律遵循“先鉴权再查数”的顺序不能只依赖前端隐藏入口。6. 从文档到能提交的成品验证手段与快速落地技巧项目写完只是第一步真正让一份“基于 Java 的房屋租赁系统设计与实现”拿得出手的关键在验证。我建议至少测三轮。第一轮是账单重算验证写一个脚本把已生成账单按月重新计算一遍和库里存的值逐一比对金额不一致就说明生成逻辑或程序有改动但账单没重新生成。第二轮是并发验证用 JMeter 或简单脚本对同一房源发起 10 个并发签约请求观察是否只有 1 个成功、9 个返回业务异常。第三轮是越权验证手动构造请求把合同 ID 换成其他数据确认接口返回 403 或空结果而不是对方的数据。这三轮跑完系统的可信度会明显上一个台阶。文档部分建议按三件套准备需求说明、数据库设计说明、接口说明。需求说明不写长篇小说用功能清单加业务流程描述就够比如“租客提交看房申请后管理员在后台确认时间并生成预约记录”这种一句一流程的写法。数据库设计说明放表结构、字段注释、ER 图字段注释直接用建表语句里的 COMMENT 导出省时还不会和实际代码脱节。接口说明把每个接口的请求参数、响应结构、异常码列清楚写的时候对照 controller 层代码整理保证文档和实现一致这一步在答辩时非常加分。最后分享一个我自己做这套系统时的教训。当年做合同到期提醒功能我用一条定时任务每天扫描合同表里的end_date打算提前 7 天发送站内信给管理员。代码写完后发现测试数据里合同的结束日期全是当年 12 月 31 日导致系统上线后前 200 多天一条提醒都没发过最后几天突然爆发几百封站内信。后来我把测试数据的日期改成推进式生成每个月都有一部分合同在下月到期提醒逻辑才算真正被验证到。这个坑说明了一个道理像房屋租赁这种强依赖日期与状态的业务测试数据本身必须贴近真实分布不然代码的逻辑分支是黑的还是白的你根本不知道。希望这个思路对你有用也祝你交付顺利。本文还有配套的精品资源点击获取