做公寓管理平台之前,我管过一段时间青年公寓的日常运营。几百间房,Excel里记状态、微信里翻聊天记录找合同、月底抄表对账能对到凌晨。后来实在忍不了,自己用SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0 从零搭了一个青年公寓服务平台,把房源管理、租客签约、水电费账单、退租清算、看房预约、维修工单这些环节全部串起来。这篇文章把我真实的项目拆解、技术选型取舍、数据库设计思路、核心业务逻辑和踩过的坑整理出来,适合准备做公寓类管理系统、或者想用这套技术栈完成一个完整前后端分离项目的同学参考。项目本身是完整可运行的,也配了开发文档和接口文档,我会在后文中专门讲讲文档在联调里有多重要。
1. 公寓管理平台的需求画像——它到底和普通民宿/长租系统差在哪
1.1 青年公寓不是"换了个名字的房屋租赁"
很多人一听到公寓管理系统,第一反应是:不就是房源发布加合同管理吗?真做起来会发现,青年公寓的业务模型比普通住宅租赁要复杂得多。
首先是房间规模。一栋楼几百间房是常态,房间要按楼栋、楼层、户型、朝向做多维度筛选,还得实时标注空置、已租、维修、锁定几种状态。其次是租期灵活。青年公寓的租客流动率很高,短租一个月、长租一年、中途换房的情况经常发生,合同变更频率远高于传统租赁。再次是费用结构,租金只是基础,水电燃气得按房间独立抄表、公摊费用要按人头分摊,退租的时候还要做抄表清算、押金抵扣、退款计算。最后是运营动作,包括保洁、维修、催缴、看房预约这类日常管家服务,都需要在系统里留痕。
如果把这些业务全部塞进一套简单的房源管理软件里,流程根本走不通。所以做系统设计之前,必须先搞清楚:这个平台不是在管房子,而是在管一套围绕房间状态的运营流程。
1.2 系统要服务的三类使用者
我定的用户模型分三层:管理员、管家、租客。
管理员管的是全局数据,比如所有楼栋房间的统计分析、合同审核、账单复核、人员账号配置;管家负责日常执行操作,比如录入房源、发起签约、登记抄表、填写维修单、带客看房;租客通过租客端查看自己的合同、账单、缴费记录,提交报修和退租申请。这三类人的操作权限完全不一样,后端必须基于角色做接口级校验,前端也要根据角色控制菜单和路由,不能只是按钮隐藏一下应付了事。
1.3 从房源录入到退租清算,把流程完整走一遍
我当时在写需求文档时,把核心链路梳理成这样:房源录入 -> 房间状态设为空置 -> 管家在后台发布可租房源 -> 租客提交看房预约 -> 看房确认 -> 签约并交纳押金 -> 房间状态改为已租 -> 每月生成租金和水电账单 -> 租客缴费 -> 租客提交退租申请 -> 管家抄录水电表 -> 系统计算应退押金 -> 退租完成、房间回到空置。
这个流程里有一个关键点:房间状态不是简单的新增、修改、删除,而是状态流转。签约时得校验房间必须是空置;退租时得校验没有未结清的账单。这些校验逻辑不能分散写在各个Controller里,得收敛到统一的领域服务中。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型的真实理由——为什么停在SpringBoot2.x而不是追新
2.1 SpringBoot2.x在现在还算不算过时
这个项目用的是SpringBoot2.x,确实不是3.x最新版。但我认为在做这类管理系统时,选型要服务于存量生态,而不是版本焦虑。SpringBoot2.x配合JDK8是目前很多团队生产环境的标准组合,依赖生态最成熟,网上能查到的解决方案也最丰富。SpringBoot3.x确实性能更好,但它强制JDK17及以上,团队如果还有老服务跑在JDK8上,技术栈统一是个现实问题。另外MyBatis-Plus对SpringBoot2的集成文档非常完整,很多自动化配置开箱即用,开发效率高。
当然如果项目是从零开始,且团队已经全面切换到JDK17,那直接用3.x也没问题。我这里选2.x纯粹是考虑到运行环境和依赖兼容性。
2.2 MyBatis-Plus解决了MyBatis一半的重复劳动
持久层我选了MyBatis-Plus,而不是原生MyBatis,更不是Spring Data JPA。主要原因有几个。
第一是BaseMapper自带单表CRUD,不用为每个Mapper写基础XML。第二是LambdaQueryWrapper用起来非常顺手,条件构造跟写自然语言一样,比如查空置房间:
java复制LambdaQueryWrapper<Room> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Room::getStatus, RoomStatus.EMPTY)
.eq(Room::getBuildingId, buildingId)
.orderByAsc(Room::getRoomNo);
List<Room> roomList = roomMapper.selectList(wrapper);
第三是分页插件内置了物理分页,一句PaginationInnerInterceptor搞定。第四是@TableLogic逻辑删除,省去各种手动拼deleted = 0的条件。这套组合在管理系统里效率提升非常明显。
2.3 Vue3 + Vite + Element Plus,前端工程化的标准打法
前端用的是Vue3 + Vite + TypeScript + Element Plus。Vite的冷启动和热更新比Webpack时代的体验好太多,几乎保存即刷新。组合式API配合<script setup>写业务逻辑也很干净,不用像Options API那样把数据、方法、计算属性拆得到处都是。
状态管理用的Pinia,相比Vuex少了Mutations那一堆样板代码,在管理后台这种大量跨组件共享用户信息和角色权限的场景下非常合适。Element Plus的表格、弹窗、表单组件比较全,做后台管理系统基本不用自己造轮子。
2.4 MySQL8.0带来的不只是版本号变化
数据库用MySQL8.0,出发点是几件事:窗口函数做排行榜和房间统计很方便,比如按楼栋统计空置率就是一个ROW_NUMBER() OVER(PARTITION BY ...)的事;utf8mb4字符集对生僻字和表情符号支持更好,租客姓名里偶尔出现生僻字不会报错;InnoDB引擎的性能和事务能力也更成熟。
但MySQL8.0有一个最容易被坑的点:连接串必须加serverTimezone=Asia/Shanghai,否则系统时间和数据库时间会差8个小时。这个问题后面避坑篇会专门展开。
3. 数据库设计——十几张表如何撑起整个公寓业务
3.1 核心表的关系与角色
整个项目大概有十几张业务表,我先按核心链路把它们分组:
- 房源侧:公寓表、楼栋表、房间表
- 租客侧:租客表、租客账号表、看房预约表
- 契约侧:合同表、合同变更记录表
- 费用侧:账单表、收款流水表、水电燃气抄表记录表
- 运营侧:维修工单表、保洁任务表、操作日志表
表之间的核心关系是这样:楼栋属于公寓,房间属于楼栋,合同挂房间和租客,账单挂合同,收款流水挂账单,抄表记录挂房间。所有关联都通过外键逻辑关联,物理外键我一般不加,因为管理系统的数据量在千万级以下,物理外键的维护成本反而高,靠代码保证数据一致性就够了。
3.2 字段设计的几个关键决策
房间表里,状态字段是关键。我用status字段表示空置、已租、维修、锁定,搭配rent_price当前挂牌价。这里有个容易忽略的点:合同表里也要冗余一个rent_price快照,因为房间价格会调整,历史合同必须保留签约时的价格,不能跟着房间表变。
合同表的状态字段我用了0待生效 / 1生效中 / 2已到期 / 3已退租 / 4已违约。这个状态会有流转过程,不能只靠前端按钮控制,后端要做校验,防止从"待生效"直接跳到"已退租"这种非法操作。
金额字段全部用decimal(10,2),Java侧对应BigDecimal,之前见过有人用double存金额,退款的时候精度对不上,后面会专门讲。时间字段全部用datetime,不建议用timestamp,因为2038年问题虽然远,但习惯用datetime维护起来少一个心智负担。
3.3 三条少不了的SQL
这类系统有几条SQL是高频请求,我先把思路拿出来。
查可租房间:排除状态不是空置的房间,同时要过滤掉合同还没到期但房间状态没改过来的脏数据。
sql复制SELECT r.*
FROM room r
WHERE r.building_id = ?
AND r.status = 0
AND NOT EXISTS (
SELECT 1 FROM contract c
WHERE c.room_id = r.id
AND c.status IN (0, 1)
);
月度账单汇总:按楼栋统计某个月的应收总额,这在管理后台首页图表里必用。
sql复制SELECT b.name AS building_name, SUM(bill.amount) AS total_amount
FROM bill
JOIN contract c ON bill.contract_id = c.id
JOIN room r ON c.room_id = r.id
JOIN building b ON r.building_id = b.id
WHERE bill.bill_month = '2025-03'
GROUP BY b.id, b.name;
合同到期提醒:提前7天找到即将到期且未续租的合同,定时任务每天扫一遍。
sql复制SELECT c.*, t.name AS tenant_name, r.room_no
FROM contract c
JOIN tenant t ON c.tenant_id = t.id
JOIN room r ON c.room_id = r.id
WHERE c.status = 1
AND DATEDIFF(c.end_date, CURDATE()) BETWEEN 0 AND 7;
4. 后端不能糊掉的三块硬骨头——状态机、账单、权限
4.1 签约和退租的状态流转,用状态机收口
业务里的状态流转是出bug的重灾区。合约状态、房间状态、账单状态,如果校验逻辑散落在各个Service里,今天在签约接口加一个判断,明天在退租接口加一个判断,很容易出现前后不一致。
我用的办法很朴素:写一个状态机校验器,把允许的流转关系集中定义,所有接口在改状态前先校验一次。
java复制public class ContractStateMachine {
private static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = new HashMap<>();
static {
ALLOWED_TRANSITIONS.put(0, Set.of(1, 4)); // 待生效 -> 生效中 / 违约
ALLOWED_TRANSITIONS.put(1, Set.of(2, 3, 4)); // 生效中 -> 已到期 / 已退租 / 违约
ALLOWED_TRANSITIONS.put(2, Set.of(3)); // 已到期 -> 已退租
}
public static boolean canTransit(int from, int to) {
Set<Integer> allowed = ALLOWED_TRANSITIONS.get(from);
return allowed != null && allowed.contains(to);
}
}
签约时,先查房间状态是否为空置,再插入合同并修改房间状态。这里有个细节:房间状态修改和合同插入必须放在同一个事务里,否则会出现合同写了但房间还是空置的数据不一致。
4.2 水电费账单:租金固定、用量变动的组合逻辑
账单生成是公寓系统里逻辑最绕的地方之一。租金是合同里固定的,每月生成一次;水电费是根据抄表记录动态计算出来的,刷新一次抄表就重新生成一条明细。
我的实现思路是:每个自然月月初的定时任务先给所有生效中的合同生成基础租金账单,这笔账单只有租金;管家录入当月水电抄表后,系统自动计算出各房间的实际用量和费用,再把账单金额更新为租金+水电费,并记录一条明细到账单详情表。
这样做的优势是账单流程可追溯:既保证租金不会被漏,又能灵活处理抄表时间参差不齐的情况。如果某房间没抄到表,可以单独等补录再计算,不影响其他房间。
4.3 定时任务做合同到期提醒
合同到期提醒我用Spring的@Scheduled做,配置成每天凌晨跑一次,扫出7天内即将到期的合同,插入提醒表,并通过站内信和短信模板通知管家。
定时任务有个多实例问题:如果系统部署在多台服务器上,同一个定时任务会重复执行。我在项目里用了一个简单的方案:任务执行前先查分布式锁记录,如果当天已经执行过就直接跳过。单机部署可以简单点,但一旦考虑高可用,这个问题要提前想好。
4.4 权限模型:管理员、管家、租客三类角色怎么落地
权限模型用RBAC落地,基础是:用户 -> 角色 -> 菜单/权限点,权限点用字符串标识,比如room:add、contract:sign、bill:refund。
后端用拦截器统一做接口级权限校验,前端在登录后根据角色动态生成路由和菜单。这里我特别想强调一下:前端路由可以做权限控制,但那只改善体验,不能作为安全边界。真正管用的是后端接口权限,一定不能漏。
对于租客端,我单独做了账号体系,租客账号绑定到租客身份上,同一身份证可以关联多份合同。这样租客换了房间,账号不用重新注册。
5. Vue3前端——路由、数据请求、联调中的关键决策
5.1 Vite代理解决跨域,省掉Nginx调试的麻烦
前后端分离开发时,跨域是第一个要解决的问题。我前端本地开发地址是localhost:5173,后端接口是localhost:8080,如果直接发请求,浏览器会拦截。方案有两种:后端加CORS配置,或前端配Vite代理。我选的是Vite代理,因为它只影响开发环境,生产环境只需要把前后端放到同一个域名下,用Nginx转发,不用在后端代码里为跨域做额外处理。
javascript复制// vite.config.ts
export default defineConfig({
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
});
这样前端请求/api/room/list时,开发服务器会自动转发到后端8080端口。
5.2 动态路由:菜单是根据角色生成的
管理后台的菜单不是写死在前端路由表里的,而是在登录后根据当前用户的角色从后端拉取。后端返回该角色可见的路由列表,前端用router.addRoute逐个注册,这样管家登录后看不到管理员的管理菜单,租客登录后直接跳到租客端页面。
这个方案的问题在于刷新页面时,动态路由会丢失。我的做法是在每次页面刷新后重新拉取用户信息和路由表,做一个统一的初始化流程:
javascript复制async function initApp() {
const userStore = useUserStore();
if (!userStore.token) {
router.push('/login');
return;
}
if (!userStore.permissions.length) {
const info = await authApi.getUserInfo();
userStore.setUserInfo(info);
const routes = generateRoutes(info.roles);
routes.forEach(route => router.addRoute(route));
}
await nextTick();
router.replace(router.currentRoute.value.fullPath);
}
5.3 接口文档的实际价值,比大多数人想象中大
标题里带了"含文档",这套项目确实配了开发文档和接口文档,我在实际联调中体会特别深。前后端并行开发是常态,前端不可能等后端全部写完再开工。接口文档约定好了:接口路径、请求方法、入参字段、出参结构、返回码含义、示例数据,前端就能用Mock数据先行渲染页面,后端按文档实现接口,最后联调时只需要对比字段是否一致。
而且文档还有一个容易被忽略的好处:它强制我梳理接口边界。写文档的时候会自然想到"退出登录这个接口需不需要校验token"、"分页参数到底从0开始还是从1开始",这些问题如果等到联调才发现,来回沟通成本很高。所以后来我养成了一个习惯:Controller层写代码之前,先写接口文档。
5.4 长列表页的优化:分页、筛选和导出
公寓后台最常见的页面就是房间列表、账单列表、租客列表。刚开始我直接全量查出所有数据渲染,房间不到一千间时没觉得卡,但加上状态标签、操作按钮和筛选组件后,每次输入筛选条件都触发全量刷新,体验很差。
后来统一改了方案:列表接口全部走分页,条件筛选在后端用Wrapper拼装,前端表格只渲染当前页,数据量控制在50条以内。另外导出了Excel功能,这个功能在账单列表里特别实用,管家月底对账直接导出表格,不用再找开发提需求。
这里要注意的是,导出操作不能再走分页查询,前端传当前筛选条件,后端重新查询全量数据流式写入Excel,数据量大了之后要考虑异步生成文件再下载的流程。
6. 从失败里捞出来的六条避坑经验——照着查能少熬两晚上
6.1 逻辑删除和唯一索引打架
租客表里手机号设了唯一索引,方便登录和查重。后来加了逻辑删除,问题就来了:租客注销账号后,数据库里那行记录并没删掉,只是deleted字段置为1。当同一个手机号的新租客再注册时,唯一索引直接报错:Duplicate entry。
我当时的解决方式是,在唯一索引里掺入删除标记:把索引字段改成(phone, deleted),并且删除数据时将deleted字段改成当行主键id,这样既保留了逻辑删除,又让旧记录不再阻塞新注册。这个坑很典型,建议所有用了@TableLogic的团队都提前想想唯一索引的问题。
6.2 MySQL8.0时区问题,时间差8小时
开发时我发现一个问题:插入数据库的时间比本地时间少8个小时。第一反应是前端时间传错了,后来用Postman直接调接口,时间还是不对,才意识到是数据库连接串的问题。
MySQL8.0默认时区是UTC,和北京时间的东八区差了8小时。解决办法是在JDBC连接串中明确指定时区:
code复制jdbc:mysql://localhost:3306/apartment?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true
这里还有个细节:allowPublicKeyRetrieval=true是MySQL8.0连接时经常需要的参数,不加可能会报Public Key Retrieval is not allowed,浪费半天时间查这个问题真的不值。
6.3 金额千万别用double
最早我图省事,账单金额字段直接用了double。结果有一次测试退款时,明明算出来应该退98.1元,实际生成的数据却是98.09999999999999。排查后发现double在二进制里无法精确表示很多小数,累计多了就会出问题。
之后所有金额字段全部换成decimal(10,2),Java侧用BigDecimal,计算也一律通过BigDecimal的方法完成,禁止直接做加减乘除。这个决定让我在后面处理押金退款、公摊分摊时省了很多心。
6.4 MyBatis-Plus乐观锁失效
给账单表加了@Version字段做乐观锁,防止并发重复退款。结果实测发现乐观锁没生效,重复请求还是能成功更新。
排查下来原因是updateById时,如果实体对象没有显式设置version字段,MyBatis-Plus会从数据库查一次再带着原值更新,看上去没问题;但我在某些更新逻辑中自己重新new了一个实体并塞了version的新值,等于把版本号提前改了,乐观锁自然失效。
后来的规范是:所有涉及乐观锁的更新操作,必须先从数据库查出原始实体,修改业务字段后原样提交,不能手动修改version字段。
6.5 Vue3响应式丢数据
前端踩过一个Vue3的坑。我在reactive中定义了一个列表数组,从接口拿到数据后直接用等号整体赋值,结果页面怎么都不刷新。
原因是在Vue3的响应式代理中,直接替换一个reactive对象的数组属性是可以触发更新的,但如果直接替换整个reactive对象内部的某个嵌套引用,某些场景下代理会丢失响应式。我的解决办法是改用ref来管理列表数据,赋值时用.value;或者先清空数组再通过push逐条添加。这个问题到后面干脆统一了规范:接口返回的列表数据一律用ref管理。
6.6 定时任务在集群环境重复跑
合同到期提醒任务一开始只部署在一台服务器上,没问题。后来准备做双机部署时发现,如果两台服务器都跑同一个@Scheduled定时任务,合同到期提醒会被发两次,租客和管家都会收到重复通知。
我解决的方式是引入了一张任务执行记录表,每次任务开始前先尝试插入一条带唯一业务标识和日期的记录,插入成功才执行任务逻辑,插入失败说明另一台机器已经在跑。这个方案虽然简单,但对这类小系统足够了,不需要引入分布式任务调度中间件。
写到最后,我再分享一点个人感受:这类管理系统的技术难点不是某个框架的API,而是怎么把散落在业务人员脑子里的流程,转化成清晰的状态流转和数据关系。项目里最有价值的文档不是代码注释,而是那份把所有业务规则列出来的开发文档。后续如果你想扩展,可以考虑接入微信支付押金、对接水电表IoT设备自动抄表、生成月度经营报表这些方向,整个平台会从"管理工具"变成真正的"运营中台"。
