做房屋租赁管理这类系统,最怕的不是没有需求,而是需求是一块一块长出来的。我前前后后接触过好几家做长租公寓和房屋中介的团队,他们的日常是这样的:几百间房源的租客信息、合同期限、收租记录全部摊在Excel里,房租到期靠人肉翻日历,空置房源挂在几个平台上同步,月底做账能对到半夜。后来遇到这种情况,我的标准答案不是去调研海外平台怎么设计,而是直接给一套SpringBoot+Vue+MyBatis+MySQL的完整系统源码,让他们在自己的服务器上一跑,所有租赁流程全部线上化。
这套企业级房屋租赁管理系统,前后端完整分离,后端是SpringBoot+MyBatis,数据库MySQL,前端Vue3+Element Plus。它不是一个只演示接口和管理员列表的教学Demo,而是把房源、租客、合同、账单、收银、报修、角色权限都做成完整闭环的项目。正在做毕业设计或者想拿一个全栈项目练手的同学,可以直接拿它当蓝本;团队需要搭建自用租赁管理后台的,也可以在这套源码上做二次开发。
1. 项目背景与整体技术架构
1.1 企业级租赁系统的核心需求拆解
“企业级”三个字,听上去很宏大,但落到租赁业务其实很具体。核心是业务闭环和角色划分。我见过不少项目,界面做得很漂亮,但业务是断的:合同签了,房子状态还是空置;租客退租了,账单还在每个月自动生成;月底统计应收和实收,两边对不上。
做这套系统时,我一开始就做了需求拆解,而不是直接写代码。拆解下来主要有四个点:
- 房源全生命周期:从楼栋、房间建档开始,经历空置、待租、签约、已租、到期、退租、维修,每个状态都要有据可查,谁操作的、什么时间操作的,不能只是改个数字。
- 合同与账单是闭环核心:合同不是一张 PDF 存进去就完了,它是租金的源头。合同生效要自动生成账单,合同退租要把未缴账单结清,账单逾期要有滞纳金规则。
- 多角色协同:管理员、财务、运营各自关注的东西不一样。财务要收银和对账,运营要看空置和到期,管理员要管账号和权限。企业级意味着不能所有账号进去都看到一模一样的菜单。
- 统计分析:房租收入、空置率、当月到期合同、收缴率,月底能一键出报表。没有统计的租赁系统只能叫登记系统,不能叫管理系统。
需求拆解之后,剩下的工作其实是把状态和链路设计好,开发只是执行。
1.2 为什么选SpringBoot+Vue+MyBatis+MySQL
这个技术栈组合,老实说不是最时髦的,但它是做这类业务系统最稳妥的方案。
SpringBoot胜在生态成熟,招人容易,社区里能搜到的问题答案多。对企业项目来说,可维护性比炫技重要。SpringBoot内置Tomcat,打jar包就能跑,部署成本低得可怜。这里要特别提醒一句:如果团队环境还在用JDK8,SpringBoot建议用2.7.x系列。Spring Boot 3以上至少要JDK17,很多老机器和旧运维脚本不一定支持,没必要为了追版本踩坑。
MyBatis的选择理由也很直接。租赁业务里有大量对账统计SQL,需要精确控制SQL的执行计划,MyBatis的XML映射能把复杂SQL写得很直白,也方便DBA直接review。JPA不是不好,但遇到要手写统计SQL的场景,JPA的抽象反而是一种阻碍。手写SQL虽然有工作量,但性能可预期。
Vue做后台管理系统的效率在国内几乎没有对手。Vue3+Element Plus的组合,表格、表单、日期选择器、弹窗、穿梭框都是现成的,前端团队很容易上手,开发速度非常快。
MySQL在数据量没有到数百万级之前完全够用,成本也低,配合索引设计,支撑几百套房源的租赁管理毫无压力。这套组合,属于“下限很高、上限够用”的选择。
1.3 项目结构总览
源码仓库的组织方式,直接影响二次开发的效率。我习惯把后端根包命名为com.rent,按职责分层,不按业务模块堆包:
controller:接收请求、参数校验、返回统一响应service:业务逻辑、事务控制、状态流转mapper:MyBatis接口,XML写在resources/mapper目录entity:数据库实体dto:请求参数模型vo:视图模型,返回给前端的数据结构config:跨域配置、拦截器、定时任务配置等
前端src目录也做了明确约定:
api:按业务模块封装的接口方法views:页面组件,一个业务模块一个文件夹router:路由配置store:Pinia状态管理utils:axios实例、请求封装、日期工具constants:状态字典、枚举值
一个比较实用的约定是:controller层只做参数接收和校验,不写业务逻辑;一个controller对应一个业务模块,命名与前端路由对齐。这样前后端联调时,两边说“房源列表”都知道去哪个文件改,沟通成本很低。
2. 数据库设计:把业务状态理清楚
2.1 核心数据表与关系
数据库是这套系统的地基,表与表之间的关系一旦定错了,后面改起来极其痛苦。我设计的核心表大概有这些:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| sys_user | 系统用户 | username、password、status |
| sys_role / sys_user_role | 角色与用户关系 | role_code、remark |
| sys_menu / sys_role_menu | 菜单与角色关系 | menu_name、path、perms |
| building_info | 楼栋/小区基础信息 | building_name、address |
| house_info | 房源/房间信息 | house_no、building_id、area、status |
| tenant_info | 租客档案 | name、phone、id_card |
| lease_contract | 租赁合同 | contract_no、house_id、tenant_id、start_date、end_date、monthly_rent |
| rent_bill | 租金账单 | contract_id、bill_month、amount、due_date、status |
| payment_record | 缴费流水 | bill_id、amount、pay_method、operator |
| repair_order | 报修工单 | house_id、content、status |
| operation_log | 操作日志 | operator、action、detail |
这些表之间的关系并不复杂:house_info一对多lease_contract,因为同一个房源历史上会有多份合同;lease_contract一对多rent_bill,一份合同对应每个月的一张账单。tenant_info和lease_contract也是类似逻辑,当前合同关联一个租客,但历史合同也要保留。
这里有个很容易忽略的细节:合同表里要冗余房源快照字段。比如合同里存了当时的house_no、house_address、monthly_rent。这样做是因为如果后续房源调价、改门牌号,历史合同不能被牵连。这类冗余在业务系统里叫“业务快照”,比每次都去join查询更可靠,也更符合审计要求。
2.2 状态设计:租赁业务最关键的地方
租赁系统的状态设计,比表关系更能体现系统的“企业级”水平。我给三类核心数据各设计了一套状态:
| 对象 | 状态值 | 含义 |
|---|---|---|
| 房源 house_info.status | 1 / 2 / 3 / 4 | 空置 / 已租 / 维护中 / 锁定 |
| 合同 lease_contract.status | 0 / 1 / 2 / 3 / 4 | 草稿 / 生效中 / 已退租 / 违约终止 / 到期结束 |
| 账单 rent_bill.status | 0 / 1 / 2 / 3 | 待缴 / 已缴 / 逾期 / 已作废 |
状态流转是有固定路线的,不能随意跳。比如签一个新合同,流程必须是:
- 校验房源状态必须是“空置”
- 创建合同,状态置为“生效中”
- 房源状态改为“已租”
- 生成首期账单
退租流程则是:
- 校验合同必须是“生效中”
- 结算未缴账单
- 合同状态改为“已退租”
- 房源状态改回“空置”
这些状态流转我在service层统一封装,在代码里强制约束,不依赖前端自觉。前端就算绕过按钮直接调接口,后端也会拒绝非法状态变更。这里也解释一下为什么不用数据库外键:企业级多人并发使用,外键在插入和删除时容易造成锁等待,报错信息也很难让运维理解。用业务字段加索引,在service层统一控制关系,更灵活也更可控。
2.3 索引设计与关键查询
租赁系统最常见的操作,其实是几个固定场景:按状态筛选房源、按合同扫描到期、按账单扫描逾期、按租客查历史合同。所以索引设计要围绕这些查询来。
house_info:idx_house_status(status)、uk_house_no(house_no)、idx_building_id(building_id)lease_contract:uk_contract_no(contract_no)、idx_house_id(house_id)、idx_tenant_id(tenant_id)、idx_end_time(end_time)rent_bill:uk_contract_month(contract_id, bill_month)、idx_status(status)、idx_due_date(due_date)tenant_info:idx_phone(phone)
为什么给end_time和due_date建索引?因为系统每天要用定时任务扫这些字段。如果没有索引,房源几千条的时候感觉不出来,等数据量到几万条,定时任务会越跑越慢,挤占业务数据库IO。加一个普通索引,扫描成本直线下降。
统计SQL也要提前想好。比如统计空置率:
SELECT SUM(CASE WHEN status = '1' THEN 1 ELSE 0 END) AS empty_count, SUM(CASE WHEN status NOT IN ('1', '4') THEN 1 ELSE 0 END) AS occupied_count, COUNT(*) AS total_house FROM house_info WHERE building_id = #{buildingId};注意统计口径:空置率的分母一般是“可租状态房源数”,而不是“全部房源数”,因为维护中和锁定的房源本来就不参与出租,混在一起统计会把空置率算得虚低。这种细节一开始就要定清楚,后面报表才可信。
3. 后端核心模块实现细节
3.1 统一响应与全局异常处理
前后端分离之后,接口规范是第一件事。我定了统一的返回结构:
{ "code": 200, "msg": "success", "data": {} }code=200表示成功,400表示业务失败(比如房源已出租),401表示未登录或登录过期,403表示无权限,500表示系统异常。
为什么做这个统一?因为前端每个接口都要处理成功、失败、弹提示、跳登录这几件事。如果十个接口有十种返回结构,联调会变成灾难。统一之后,前端axios拦截器只需要处理一套逻辑。
全局异常处理用@RestControllerAdvice实现。Service层抛出的BizException会被捕获,转成code=400和对应的提示信息;其他未知异常打印完整堆栈后返回code=500的友好提示。要注意的是,不要把内部异常信息原样返回给前端,比如堆栈里的服务器路径、SQL语句,这些信息暴露出去是不安全的,给个“系统繁忙,请稍后重试”就够了,详细日志留在后端文件里。
3.2 MyBatis工程化配置与动态SQL
MyBatis要配置好几个核心项,最容易被新手漏掉的是驼峰映射:
mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.rent.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这个配置一开,数据库的create_time字段会自动映射到实体类的createTime属性,省掉一大堆手写resultMap。很多人查出来的实体字段全是null,十有八九就是没开这个开关。
动态SQL是MyBatis做条件筛选的王牌。房源列表的筛选查询就这么写:
<select id="selectHouseList" resultType="House"> select * from house_info <where> <if test="status != null and status != ''"> and status = #{status} </if> <if test="buildingId != null"> and building_id = #{buildingId} </if> <if test="keyword != null and keyword != ''"> and (house_no like concat('%', #{keyword}, '%') or house_name like concat('%', #{keyword}, '%')) </if> </where> order by house_id desc </select>有几个注意事项:
- 不要让前端把条件拼成SQL字符串传过来,必须用
#{}参数绑定,防止SQL注入。${}不是不能用,但使用时必须保证传入值在白名单里,比如排序字段。 - 用PageHelper分页时,
PageHelper.startPage()必须放在Mapper方法调用之前,而且同一线程不要多次调用,否则查询结果会错乱。 - 不要在for循环里一条一条查数据库,能用一条
in查询解决就不要循环去查,避免N+1问题。
3.3 签约退租:一个完整的事务闭环
签合同这段逻辑,是整个系统里最需要谨慎的。我先给一段核心代码:
@Transactional(rollbackFor = Exception.class) public ContractVO createContract(ContractCreateRequest request) { House house = houseMapper.selectByIdForUpdate(request.getHouseId()); if (house == null || !"1".equals(house.getStatus())) { throw new BizException("房源不存在或当前不可签约"); } Contract contract = buildContract(request); contractMapper.insert(contract); houseMapper.updateStatus(request.getHouseId(), "2"); billService.generateFirstBill(contract.getId(), request.getFirstMonthRate()); logService.record("create_contract", "创建合同 " + contract.getContractNo()); return ContractVO.from(contract); }这段代码里最值得琢磨的是selectByIdForUpdate。为什么查询房源要加锁?因为在两台服务器并发的情况下,两个人同时看到这个房源待租,一起点了签约。如果只是普通select,两个请求都会通过状态校验,都会插入合同,房源就被租出去两次了。用select ... for update把这行锁住,第二个请求会等第一个请求提交后再判断,这时房源状态已经变成“已租”,直接抛业务异常。
@Transactional(rollbackFor = Exception.class)保证整个流程的原子性:合同插入、房源状态更新、账单生成、日志记录,要么全部成功,要么全部回滚。如果不加事务,就有可能出现“合同建了、房子还是空置”这种历史遗留问题。我在一线见过太多次,几乎都是漏了事务或某个更新语句被吞了异常。
退租逻辑同理:结算未缴账单、合同状态改成已退租、房源状态改回空置,这三个动作必须在一个事务里完成。退租完成后还要写一条退租记录,包含最终的租金结算情况,方便后续财务对账。
3.4 定时账单与逾期滞纳金
租金账单不能靠人工去录,否则月底财务非疯了不可。我用Spring的@Scheduled做定时任务:
- 月账单生成任务:每月1号凌晨2点,扫描所有状态为“生效中”的合同,为每份合同生成当月账单。账单号有唯一约束
(contract_id, bill_month),防止重复执行时产生重复账单。 - 逾期扫描任务:每天凌晨跑一次,扫描所有
due_date < 今天且状态还是“待缴”的账单,把状态改成“逾期”,同时按规则计算滞纳金。
滞纳金规则我建议用“固定比例+上限”的方式。比如每天按应收金额的0.5‰计算,但最高不超过应收金额的20%:
滞纳金 = min(应收金额 * 0.0005 * 逾期天数, 应收金额 * 0.2)如果不设上限,几年没缴的账单滞纳金会超过本金,租客肯定不认,运营也解释不清楚。这个上限要在系统说明文档里写清楚,最好在租约合同模板里也体现。另外,账单生成后要支持“作废”功能,比如录入错误或者租客提前退租,但作废操作一定要留日志,谁作废的、为什么作废,都要有记录。
3.5 权限安全:RBAC + JWT
企业级系统的安全比“能登录就行”要多两个层级:角色权限和操作权限。我在这套系统里做了标准的RBAC模型:用户关联角色,角色关联菜单和按钮权限。
登录成功后,后端签发JWT token,前端存起来,每次请求在Header里带Authorization。后端用一个HandlerInterceptor拦截除了登录、菜单等白名单以外的所有接口,校验token有效性和用户状态,解析出用户ID放到ThreadLocal里,业务层直接取。
为什么不用Spring Security?不是因为它不好,而是对多数中小项目来说,自定义拦截器+RBAC表反而更容易理解,出问题也好排查。Spring Security的过滤器链概念对新人来说门槛偏高,而这里的需求就是“校验token、校验权限”,一个拦截器就能覆盖。
密码必须用BCrypt加密存储。绝对不能再用MD5,MD5加盐也挡不住现代算力下的暴力破解。注册用户时用BCrypt生成哈希,登录时校验哈希。数据库里即使泄露了密码字段,也无法反推出明文。
按钮权限我用一个自定义注解@RequirePermission("rent:contract:create")标注在接口上,拦截器里判断当前用户角色是否包含该权限点。没有权限直接返回403,前端在按钮上也会根据权限集合决定要不要渲染。双端都控制,安全性才完整。
4. 前端实现:Vue3的全流程开发
4.1 工程初始化与项目依赖
前端技术栈是Vue3 + Vite + Vue Router 4 + Pinia + Element Plus + Axios + dayjs。用Vite而不是Webpack,最大的感受是开发时秒级热更新,项目几十个页面也不会等半天编译。
需要特别注意的一点:依赖版本要固定写法,在package.json里把版本号写死,不要用^这种允许浮动的写法。Element Plus和Vue之间的兼容性问题,我踩过不止一次,锁定版本能少很多麻烦。
环境变量文件要区分:
.env.development:VITE_API_BASE_URL=/api.env.production:VITE_API_BASE_URL=/api
前端代码里不写死后端地址,统一走相对路径/api,开发环境由Vite代理转发,生产环境由Nginx转发。这样换环境只改配置文件,不用改代码。
4.2 登录态与权限路由
登录成功后,token和用户信息保存到Pinia,同时持久化到localStorage。路由守卫统一处理未登录跳转:
router.beforeEach((to, from, next) => { const store = useUserStore() if (to.meta.public) return next() if (!store.token) return next('/login') return next() })to.meta.public用来标记登录页这类公开路由。菜单根据用户权限动态渲染:后端返回当前用户能访问的菜单列表,前端根据这个列表生成侧边栏,而不是把所有菜单写死。按钮级别的权限,比如“新增合同”“退租”这些操作按钮,用权限集合判断是否渲染v-if。
登录页有个小细节:只存账号不存密码。浏览器记住密码是浏览器自己的功能,应用层不要做“记住密码”这种把用户凭证暴露给本地存储的事。账号可以存localStorage,密码不行。
4.3 核心页面与业务交互
几个核心页面的设计思路:
- 工作台Dashboard:顶部四个统计卡片(在租房源、空置房源、本月应收、本月实收),中间是待办清单(即将到期合同、逾期账单),下面放近6个月收入趋势图。运营人员打开系统第一眼就能掌握全局。
- 房源管理:支持按楼栋、状态、关键字筛选。表格行内显示状态标签:空置绿色、已租蓝色、维护中橙色。行内直接提供“签约”“详情”“报修”入口,减少页面跳转。
- 合同管理:列表默认筛选“生效中”。合同创建页采用分步表单:第一步选房源(只显示空置房源),第二步选租客,第三步填租期和金额,前端实时计算总租金。这里有个交互细节:保存成功之后要刷新当前列表并保留筛选条件,而不是跳回第一页,否则用户操作几次就要重新翻页,体验很差。
- 账单收银:支持按“全部/待缴/已缴/逾期”切换,逾期账单红色高亮。收银时弹窗展示应收金额、滞纳金明细,确认后调收款接口,成功后刷新列表。
前端表单校验不能只靠后端。Element Plus表单组件的校验规则实时提示,比如手机号格式、身份证长度、日期先后顺序,前端能拦住的问题就不要让用户提交了再等后端报错。
4.4 Axios封装与前后端联调
axios实例统一封装,拦截器做两件事:
- 请求拦截:从Pinia取token,加到
Authorization头 - 响应拦截:
code === 200直接返回data;code === 401清除登录态并跳转登录页;其他code用ElMessage.error(msg)统一弹错误提示
开发环境跨域在Vite配置文件里做:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }生产环境跨域交给Nginx处理,后面部署章节会提到。
联调时最容易出问题的是时间和字段命名。后端返回的LocalDateTime默认序列化格式是数组,前端拿到根本没法用。我的做法是后端在Jackson配置里统一格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8字段命名则要求VO全部使用驼峰,接口返回JSON就是驼峰,前端不要做二次映射。前后端约定好接口文档,用Apifox或Swagger维护,联调效率高很多。
5. 部署上线与常见问题排查
5.1 本地环境准备
要跑起来这套源码,本地环境准备如下:
- JDK 8或11
- Maven 3.6+
- Node.js 16+
- MySQL 8.0(5.7也兼容)
- 开发工具:IDEA + DataGrip或Navicat
启动步骤:
- 新建数据库,导入项目根目录的
rent_manage.sql脚本 - 修改
application.yml里的数据库地址、账号、密码 - 启动后端,确认
http://localhost:8080能访问 - 前端目录执行
npm install,再执行npm run dev - 浏览器打开Vite输出的地址,先走一遍登录接口,确认token流程通
建议电脑内存16G以上,IDEA同时开后端和前端开发,再加两个数据库工具,内存小了会卡到怀疑人生。
5.2 生产部署方案
后端打包:
mvn clean package -DskipTests产出jar包,放到服务器上:
java -jar rent-manage.jar --spring.profiles.active=prod可以用systemd脚本或supervisor做进程守护,避免进程意外退出。数据库导入项目提供的SQL脚本,注意生产环境修改数据库账号密码,不要用默认的弱口令。
前端构建:
npm run build把dist目录整个传到服务器,放到Nginx的HTML目录。Nginx配置两个关键location:
server { listen 80; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/; } location / { try_files $uri $uri/ /index.html; } }/api/转发到后端,try_files是为了配合Vue Router的history模式,否则刷新当前路径会404。还有一个很容易忽略的地方:前端项目里如果有上传图片的需求,静态资源目录也要规划好,不能让Nginx和Tomcat抢同一个目录。
MySQL建库时指定字符集:
CREATE DATABASE rent_manage DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;不指定utf8mb4的话,后期一旦写入特殊符号就会报错,而且改字符集要锁表,代价很大。
5.3 常见问题排查实录
我整理了一份速查表,基本都是实际踩过的坑:
| 症状 | 原因 | 解决办法 |
|---|---|---|
| 分页总数不对 | PageHelper版本与MyBatis冲突,或startPage位置不对 | 使用pagehelper-spring-boot-starter1.4.x;startPage紧贴Mapper调用 |
| 查询结果字段为null | 没开启驼峰映射 | 配置map-underscore-to-camel-case: true |
| 登录后请求401 | token过期或拦截器白名单没配好 | 检查拦截器路径规则和token过期时间 |
| 前端跨域报错 | Vite代理没配或后端CORS没放开 | 开发用proxy,生产用Nginx proxy_pass |
| 时间格式展示成数组 | LocalDateTime默认序列化 | 配置Jackson date-format和time-zone |
| 数据库中文乱码 | 字符集不是utf8mb4 | 建库时显式设置字符集和排序规则 |
| 刷新页面404 | 前端history路由没配try_files | Nginx加try_files $uri $uri/ /index.html |
| 定时任务没跑 | 主类没加@EnableScheduling | 启动类加注解 |
再补充几条避坑心得,比上面的表格更重要:
- 不要用数据库外键强行做关联。真踩过这种坑,后来导入数据和线上改表结构都异常痛苦。业务关联在service层控制,状态由代码保证,外键只保留索引级别。
- 生产环境不要直接改数据库。要改合同状态、作废账单,必须走系统功能,这样才能在操作日志表里留下审计线索。直接改库,出了问题没有任何人能回答“谁改的、为什么改”。
- 合同历史的快照字段千万别忽略。我见过一个系统,合同金额是联表实时查房源的,后来房源调整了租金,所有历史合同金额也跟着变了,直接引发纠纷。这就是没有做业务快照的后果。
- 页面上展示的一切统计数据,都要能追溯明细。报表上显示“本月应收10万”,点击去就要能看到是哪10个合同、哪50张账单组成的。不能只给一个汇总数字,否则使用者不敢信。
这套系统的源码核心逻辑偏实,数据库结构、状态流转和事务操作彼此咬合得很紧,你拿它去改,最值得借鉴的就是这些咬合点。
最后分享一个我个人的开发习惯:拿到任何类似项目,先别急着建表,把状态流转表和核心业务链路图写在项目文档最前面,让新加入团队的人一眼就能读懂。这个动作前期多花一小时,后期能省来来回回解释的十个小时。这套租赁系统源码里我也保留了同样的文档,按上面的步骤跑起来之后,你可以很直观地看到业务状态是怎么一步步串起来的。