公寓管理系统租务闭环:合同、账单与退租清算的状态机设计
整套公寓管理系统做到第三个阶段我明显感觉画风变了。前两个阶段还在跟公寓、房间、租客档案这些基础数据打交道写起来就是标准的增删改查到了第三阶段突然要面对的是租赁合同、账单、退款、逾期这些带着钱和时间的业务稍不留神就会把自己绕进去。这个阶段的目标很明确把一套房子从签约到退租的完整业务链路跑通让合同状态、账单状态、款项记录在系统里能对上账。这篇就记录我在这个阶段做功能拆分、数据模型设计、费用计算和联调时踩过的坑以及最后采用的方案。如果你也正在做一个类似的公寓管理系统或者手头有带钱、带状态流转的业务系统要开发这部分经验可以直接照抄。1. 第三阶段不是在写CRUD是在搭一条能跑通的租务闭环1.1 我理解的第三阶段业务边界前面两个阶段如果把公寓、楼栋、房间、租客档案这些静态数据都维护完了第三阶段要做的就是动态业务运营人员选中一间房给某个租客发起签约签约成功后系统根据租期和付款方式自动生成账单租客支付后系统登记收款租期临近时系统提醒续租或退租租客申请退租后走退租清算处理水电费差额、押金退还和剩余租金结算。这一整条链路里核心实体从原来的“房间”“租客”变成了“合同”“账单”“退款单”。换句话说第三阶段的本质不是给某个表多写几个接口而是给公寓运营建立一套可审计的台账。为什么不能只靠原来的房间表加几个字段硬撑原因很简单一份合同的寿命可能长达一年甚至两年期间房间调价、租客换房、水电单价变化都可能发生。如果没有独立的合同和账单记录等合同结束再去追溯当时签多少钱、收过多少钱、退了多少押金基本无从查起。1.2 功能不是越多越好先把主链路跑通我一开始上手时列了一堆需求自动生成账单、短信提醒、逾期违约金、退租清算、财务报表、经营统计……全部铺开之后发现根本做不完。后来重新排序才把功能收敛到一条最小可用链路上。功能模块依赖的前序功能如果延期会怎样签约生成合同房态管理、租客档案系统无法进入租务流程账单自动生成合同生效无法正常收租账单支付与状态流转账单生成收款记录对不上退租清算与退款账单、款项记录合同无法正常关闭临期提醒与逾期处理账单状态、合同状态运营全靠人工盯优先级原则很简单先把“签约 - 收租 - 退租”这一条主线跑通再来处理提醒、统计、报表这些辅助能力。如果一开始就把所有功能堆上来联调的时候会非常痛苦因为你根本分不清报错是主流程的问题还是辅助功能的问题。2. 从合同到账单表结构里的快照思维2.1 合同主表价格与租期必须冻结在现场合同表是这个阶段最重要的表没有之一。设计的时候我反复强调一个词快照。合同一旦签约成功租金单价、押金金额、租期起止这些关键信息就应当原原本本存进合同表而不是去关联房间表实时读取当前价格。为什么举个实际场景房间月租从3000元调到3500元如果合同里没有存租金快照那么历史合同查询时会跟着房间价格变成3500元账面全乱了。租房合同签完字合同上写了多少就是多少不会因为房价上涨而改变。这就是快照思维。我当时建的合同主表大概长这样CREATE TABLE lease_contract ( id BIGINT PRIMARY KEY AUTO_INCREMENT, contract_no VARCHAR(32) NOT NULL COMMENT 合同编号, room_id BIGINT NOT NULL COMMENT 房间ID, tenant_id BIGINT NOT NULL COMMENT 租客ID, rent_amount DECIMAL(10,2) NOT NULL COMMENT 租金快照, deposit_amount DECIMAL(10,2) NOT NULL COMMENT 押金快照, lease_start_date DATE NOT NULL COMMENT 租期开始日, lease_end_date DATE NOT NULL COMMENT 租期结束日, status TINYINT NOT NULL COMMENT 1草稿 2生效中 3已到期 4已退租 5已作废, created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL ) COMMENT 租赁合同表;合同状态我一开始只留了“生效中”和“已退租”后来发现根本不够用。运营人员新建合同还没确认时要能保存合同到期后不一定是退租可能直接转续租退租流程走到一半也不能算完全结束。所以最终定成了五个状态草稿、生效中、已到期、已退租、已作废。每个状态能做的操作是完全不同的草稿可以改、可以作废生效中可以发起退租申请已退租后只允许查看和退款登记不能再执行签约相关操作。状态机不设计清楚后面接口校验会写得怀疑人生。2.2 账单表把“该收的钱”拆成可追踪的明细合同签约后要收钱这里我踩了第一个大坑一开始计划只用一张“收费记录表”记录所有收款后来发现问题很大——什么时候该收、哪笔钱对应哪个周期的租金、哪笔钱已经逾期根本表达不出来。最终老老实实拆了一张账单表。CREATE TABLE bill ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bill_no VARCHAR(32) NOT NULL COMMENT 账单编号, contract_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, bill_type TINYINT NOT NULL COMMENT 1租金 2押金 3水费 4电费 5杂费, period_start DATE COMMENT 费用周期开始日, period_end DATE COMMENT 费用周期结束日, amount DECIMAL(10,2) NOT NULL COMMENT 账单金额, status TINYINT NOT NULL COMMENT 0待支付 1已支付 2已逾期 3已作废 4已退款, due_date DATE NOT NULL COMMENT 应缴日期, pay_time DATETIME COMMENT 实缴时间, payment_no VARCHAR(64) COMMENT 支付流水号, created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL, UNIQUE KEY uk_bill_no (bill_no), KEY idx_contract (contract_id), KEY idx_tenant_due (tenant_id, due_date) ) COMMENT 账单表;账单表的核心价值在于每个合同会衍生出多期账单每期账单有独立的周期、金额和状态。租金月付的就按月生成押金单独生成一笔押金账单水电费则在实际抄表后生成。这样每一笔钱都能追溯到具体合同、具体时间段、具体费用类型。租客付了租金之后账单状态从“待支付”变成“已支付”同时记录实缴时间和支付流水号。如果又退租退款原账单状态变成“已退款”但这条记录不能被删掉。财务上的每一笔账都应当在历史里留痕。2.3 为什么我坚持不在查询时实时计算账单金额其实账单金额是可以算出来的比如“月租3000住了5个月就是15000”。我为什么不这么干而是每期账单都落库因为业务一旦跑起来对账场景会非常复杂。租客可能中途换房、可能水电费单价调整、可能某个账单只收了一半钱、可能因为投诉减免了一部分租金。这些情况如果都靠查询时实时计算逻辑会膨胀到没法维护。账单落库之后它就成为一笔独立存在的财务凭据。运营人员查看历史账单看到的就是当时生成时记录的金额不会因为后来房租涨价或水电单价调整而变化。这就是快照思维在账单表上的延伸。3. 租金、押金、退款分摊费用计算的边界条件3.1 月付账单与跨月拆分的计算规则费用计算是这个阶段最容易出bug的地方尤其是跨月账单。我当时的业务约定是租金按月支付签约日就是每期账单的起算日。比如租期从1月15日开始月租3000元第一笔账单按整月收也就是1月15日到2月14日这一期到期日是2月14日。听起来很简单但退租时的费用结算是另一个故事。租客3月10日退租3月10日到4月14日这一整期租金已经收了该怎么退我采用的方案是按照实际剩余天数折算。一个月按30天计算剩余天数从3月10日到4月14日一共35天应退金额 月租 / 30 × 35 3500元。水电费也是类似逻辑。每次抄表后保存“上期表底数、本期表底数、单价”三个关键字段生成费用时用差值乘以单价。这个操作同样要落快照因为一旦下次单价变了历史账单不应该跟着变。我当时把所有费用计算逻辑收敛到了一个独立的FeeCalculator服务里不散落在各个Controller中。这样后续调整计算规则时只需要改一个类测试也能集中覆盖。3.2 退租清算里的退款顺序与状态机退租是整个阶段里业务节点最多、最容易乱的操作。我梳理出来的完整流程是租客发起退租申请 - 运营确认房屋交接 - 读取最后一次水电表底数生成水电账单 - 生成退租清算单 - 财务审核退款 - 合同状态变为已退租。其中退租清算单要分三块展示待结账单列表、押金退还金额、剩余租金退还金额。退款顺序也有硬性约定先抵扣租客尚未支付的账单再退押金最后退剩余租金。举个例子押金2000元退租时发现还有一笔500元电费没付那么清算单上会显示“抵扣欠费500元实际退还押金1500元”。合同状态流转我设计成了生效中 - 退租申请中 - 清算中 - 已退租。状态不能跳变比如租客还在退租申请中运营不能直接把它改成已退租必须走完清算流程。如果退款单填写错误被作废合同状态要回到清算中不允许直接在已退租状态下修改数据。3.3 金额类型先统一成最小单位这是最基础也是最重要的一个决定Java代码里所有金额都用BigDecimal数据库字段用DECIMAL(10,2)绝对不用float和double。浮点数的坑我就不详细展开了简单说就是0.1 0.2在二进制里不是0.3算出来是0.30000000000000004。账单金额一旦出现这种精度问题财务对账根本没法做。我在项目里额外加了一个MoneyUtils工具类统一处理金额的加法、减法、乘法以及向上取整到分的逻辑。每个涉及金额的入参和出参都用BigDecimal接收避免某处不小心写了个double导致金额异常。4. 深夜定时任务逾期账单、临期提醒与自动归档的取舍4.1 每天凌晨扫描账单把“待支付”变成“已逾期”账单生成了不代表钱一定会按时到账。我的系统里每天凌晨两点跑一个定时任务扫描所有状态为“待支付”、应缴日期小于今天日期的账单把它们统一更新为“已逾期”。定时任务的逻辑非常简单Service public class BillOverdueTask { Scheduled(cron 0 0 2 * * ?) public void processOverdue() { // 1. 分页查询状态为待支付、应缴日期小于今天的账单 // 2. 逐条更新状态为已逾期写入账单状态变更日志 // 3. 组装站内信和短信提醒消息批量发送 } }为什么非要用定时任务而不是在支付回调的时候顺便判断因为租客可能一直不付钱没有任何回调会发生只有定时任务才会主动发现“这笔账已经拖了两周了”。这也是这类业务流程里很典型的思维转换不是每个业务动作都由用户操作触发系统自己也要有主动检查的能力。4.2 合同到期预警与自动归档的取舍合同到期前30天、前7天系统要分别生成临期提醒。提醒不能做成实时查询否则每次查看都重复提醒我单独建了一张remind_log表记录合同ID和提醒类型避免同一条提醒被发送多次。合同到期之后的状态我一开始想直接自动变成“已退租”后来发现不行。到期和退租是两件事到期只是租期结束但如果租客还有未结清的账单或者押金还没退合同就不能关闭。我的方案是合同到期后状态自动变为“已到期”此时如果没有任何未支付账单且押金退款已经结清再由系统自动转成“已退租”并归档否则就进入“清退待办”等运营人工处理。自动归档看起来省事实际上把太多假设塞进了系统里。账没结清、押金没退合同就自动关了后续财务报表会非常难看。这里我宁可保守一点让机器做扫描和提醒让运营做最终决策。4.3 定时任务的幂等与防堆叠定时任务跑多了有一个隐藏问题重入和堆叠。如果任务执行时间超过了任务间隔下一个任务进来后可能会重复处理同一条数据造成重复扣款或重复提醒。我的做法是加分布式锁或者用数据库唯一约束挡住重复执行。我给每个任务都设计了一张task_execution表任务名加执行日期做唯一索引。每次任务开始先插入一条执行记录如果插入失败说明当天已经跑过了直接跳过。处理账单明细数据时也采用分页扫描不在一次查询里加载全表数据防止内存溢出和锁表。5. 支付回调、跨表事务与对账兜底联调阶段的三场硬仗5.1 支付回调与本地状态更新的一致性处理接入支付渠道是让我最头疼的部分。租客扫码付钱后支付平台会异步回调我们的后端接口。回调可能因为网络超时被重发也可能在我们更新状态到一半的时候系统重启导致支付流水已经扣了钱本地账单还是“待支付”。我的处理方案是“先记录回调再更新账单”。在数据库里建一张payment_callback_log表用支付流水号做唯一索引。回调到达后先把原始报文写入这张表写入成功后更新对应账单状态为“已支付”。如果同一条流水号再次回调唯一索引会阻止重复写入后续更新直接被幂等跳过。这样即使更新账单状态失败至少回调报文已经落库。我可以写一个定时对账任务每隔半小时扫描payment_callback_log中未处理的记录把对应账单补成“已支付”避免用户付了钱但系统一直显示未支付。5.2 一个业务动作跨多张表的事务边界退租清算这个动作会同时修改多项数据更新合同状态为“清算中”、生成水电账单、生成退款单、更新押金账单状态。这些操作必须放在同一个数据库事务里否则就会出现合同状态改成了“已退租”但水电账单还没生成的脏数据。用Spring的Transactional把事务边界划好同时要注意锁顺序。我的经验是涉及同一个合同的多张表操作时所有事务都先更新合同表再更新账单表最后更新款项表。如果不同事务按不同顺序去锁这些行很容易出现死锁。统一锁顺序之后这类问题基本没有再出现。还要坚持一条原则事务内部不要调用远程接口比如短信服务或支付查询接口。远程调用可能超时一旦超时整个事务回滚会造成非常诡异的现象。远程操作放到事务提交之后异步处理。5.3 人工对账兜底系统不可能永远正确定时任务和状态机再怎么设计也挡不住运营数据录入错误、接口异常、脏数据这些问题。我在后台留了一个对账页面运营可以按账单号或支付流水号批量核对系统给出“账单状态”和“支付流水状态”的对比结果。如果发现两边对不上提供了一个“记账修正”操作只允许管理员使用并且每次操作都会记录完整的操作日志。这个功能我一开始觉得没必要后来线上跑了一阵才知道对账兜底比整个支付流程本身还重要。没有兜底功能运营发现问题只能去找开发改数据库那才真的会出大事。6. 第三阶段最容易翻车的几个细节6.1 时间精度和时区问题账单的应缴日期如果不存对逾期判断会全面错乱。我遇到过连接数据库的时区参数没有配置服务器默认时区跟数据库时区不一致导致“今天”的判断少了一天租金明明没有逾期却被标成了已逾期。最终的约定是服务器时区统一为Asia/Shanghai数据库连接串上显式加上serverTimezoneAsia/Shanghai接口传输时间格式统一用字符串所有涉及日期的字段明确使用DATE类型而不是DATETIME避免精度和时区带来的冤枉问题。6.2 BigDecimal与数据库映射问题MyBatis映射金额字段时如果数据库字段没有显式设置精度很容易出现四舍五入或精度丢失。建表时所有金额字段我都强制写成DECIMAL(10,2)Java实体类型统一用BigDecimal。查询统计时尽量用数据库的SUM聚合返回结果直接映射成BigDecimal避免中途经过Double类型。6.3 删除按钮应该变成“作废”涉及合同、账单、退款记录的表我全部取消了物理删除。逻辑上只允许做“作废”操作而且作废必须填写原因。账单如果需要作废还要走状态机已经支付的账单不能直接作废得先生成一张负数冲正账单把原金额冲掉再进行退款登记。这一条听起来像是给自己找麻烦但实际操作下来非常值得。好几个人在初期都问过“为什么不能直接删掉这条测试数据”我的回答是如果哪天真有财务审计或者需要追溯一笔异常交易这些历史记录就是唯一线索。宁可操作上麻烦一点也不能让数据凭空消失。整个第三阶段做完我最大的体会是真正考验人的不是某个单独的功能怎么写而是这些功能放在一起时状态能不能永远保持一致。每次我忍不住想加一个绕过状态机的快捷入口都会先问自己一句这个操作会把数据带到哪个状态会不会让后面的人对不上账想清楚这个问题很多设计上的纠结其实就不存在了。