1. 项目概述
先说说这个标题背后的东西。做金融项目的朋友应该都有共鸣,市面上讲借贷系统的资料大多停留在原型图或者概念层面,真正能把“发布借款业务”和“发布投资业务”这两个核心链路完整打通、并且能落到代码层面的实战教程,其实非常少。这个项目就是干这个事的:从零搭建一套可运行的借款与投资业务模块,打通从标的创建、资金冻结、撮合匹配到还款分账的完整闭环。
这个实战项目的定位很明确,适合三类人:第一类是刚入行的后端开发,想理解资金类业务的状态机到底怎么设计;第二类是金融产品经理,需要知道研发嘴里的“放款”“债权”“还款计划”到底是什么数据结构;第三类是准备面试的候选人,把这两块业务的流程和表结构讲清楚,很多金融场景题都能触类旁通。
整个项目拆成两大块:借款业务和投资业务。听起来是两个模块,但实际落地时你会发现它们根本不是孤立的,借款侧的标的一发布,投资侧就要能马上看到可投列表;投资侧一撮合成功,借款侧的还款计划就要同步生成。资金流是双向的,业务状态也是互相咬合的。
我当时拿到这个项目的时候,第一反应是先问自己一个问题:这项目到底要解决什么痛点?答案是,绝大多数金融Demo项目要么只做了借款,要么只做了投资,很少有把两边业务闭环完整串起来的。而真实现金流业务里,借款人和投资人的需求是同时存在的,平台不是单向的资金管道,而是撮合方。所以这个项目的灵魂不在于某一个接口写得多漂亮,而在于两边业务的状态同步和资金流转是否经得起推敲。
2. 借款业务核心机制拆解
2.1 借款从发起到放款的全链路设计
先聊借款侧。借款业务在真实平台里通常包含几个核心状态:标的创建、风控审核(实战中可简化为自动通过)、投标中、已满标、放款中、还款中、已结清、逾期/坏账。这个状态机是整个借款模块的地基,你后续所有接口都是在围绕状态流转做事。
我在项目里用了一张loan表来承载借款主数据,关键的字段设计如下:
| 字段 | 说明 | 备注 |
|---|---|---|
| loan_no | 借款编号 | 唯一索引,业务幂等靠它 |
| borrower_id | 借款人ID | 关联用户表 |
| amount | 借款金额 | 单位用分存储 |
| rate | 年化利率 | 存小数,比如0.12 |
| term | 期数 | 按月分期 |
| repay_type | 还款方式 | 1=等额本息,2=先息后本 |
| status | 借款状态 | 0:草稿 1:投标中 2:已满标 3:已放款 4:还款中 5:已结清 6:逾期 |
| created_at / updated_at | 创建/更新时间 | 所有表必备 |
这里有一个非常关键但很多新手会忽略的点:金额一律用分存储,不要用浮点数。你以为0.1加0.2等于0.3?在二进制浮点数里它是0.30000000000000004,放在资金系统里就是事故。所以页面上展示的元,接口传输的元,落库一律转成分。
借款状态机里还有一个容易被忽视的节点:满标确认。在真实业务里,满标不等于直接放款,平台还会做一次终审,确认借款人的资质没有变化、满标时间是否在有效期内。项目里我加了confirm_time字段,满标后需要调用确认接口才进入放款流程,这其实是很多线上Demo里没体现的“业务常识”。
2.2 借款发布接口的幂等性与防重设计
借款发布这个接口,表面上看就是往loan表里插一条记录,但真正做起来要注意的点非常多。首先是幂等性,前端可能会因为网络超时重试请求,你如果不做防重,用户点一次“发布”就多出一条借款标,这种低级错误在资金项目里是致命伤。
我的做法是:接入层通过loan_no加唯一索引兜底,同时发布接口要求前端传入一个request_id,服务端在新增借款前先用这个request_id查一下是否已处理过。如果有就直接返回已存在的借款记录,如果没有才执行新增逻辑。配合数据库唯一索引,就算极端情况下同一请求并发打进来,也只有一个能插成功,其余的会报唯一键冲突,在异常捕获里转成“重复请求”的友好提示。
另一个需要注意的点是发布借款时的参数校验。借款金额、利率、期数、还款方式这四个字段是核心业务参数,必须做组合校验。比如:金额大于0,利率在合法范围内(实操项目里我设了0.05到0.24的区间),期数在1到36之间,还款方式是枚举值内的有效选项。这些看似啰嗦的校验,恰恰是防住脏数据的最后一道防线。
项目里借款接口的核心伪代码如下:
@Transactional public LoanDTO publishLoan(PublishLoanCommand cmd) { // 幂等校验 Loan existing = loanMapper.selectByRequestId(cmd.getRequestId()); if (existing != null) { return convert(existing); } // 参数校验 validate(cmd); // 构建借款单 Loan loan = new Loan(); loan.setLoanNo(GenNoUtil.genLoanNo()); loan.setBorrowerId(cmd.getBorrowerId()); loan.setAmount(cmd.getAmount()); loan.setRate(cmd.getRate()); loan.setTerm(cmd.getTerm()); loan.setRepayType(cmd.getRepayType()); loan.setStatus(1); // 投标中 // 插入并返回 loanMapper.insert(loan); return convert(loan); }为什么状态默认是“投标中”而不是“草稿”?因为实战项目里,发布借款这个动作本身就是向市场公开邀约,发布成功即进入可投状态。如果要做草稿箱功能,应该单独走草稿接口和上架接口,而不是混在发布逻辑里。
2.3 还款计划生成逻辑:等额本息与先息后本
借款发布成功之后,有一个动作一定要在发布那一刻就同步完成,而不是等到放款后才算,那就是还款计划的生成。为什么?因为投资人在投标时需要看到这份计划,这直接决定了他要不要投这笔钱。
等额本息的计算公式是:
每月还款额 = [本金 × 月利率 × (1+月利率)^期数] / [(1+月利率)^期数 - 1]注意,这个公式算出来的是一个浮点数,但实际还款金额必须是整数分。所以这里有一个精度处理的关键步骤:最后一期的还款额要用总金额减去已经还掉的本息和,而不是直接套公式,否则会因四舍五入导致最后一期金额对不上。
举个例子,一个人借了10000元,年利率12%,分12期。月利率是1%。套公式算出来每期应还约888.49元,这个数落到分上,前11期每期还88849分,最后一期则要根据实际剩余本金重新计算,尽量保证总还款额和理论值之间的误差不超过1分钱。
先息后本更简单,前N-1期只还利息,最后一期利息加本金一次结清。但这里有个坑:利息是按实际占用资金的时间算的,不是简单月利率乘以本金。如果某个月有31天,利息应该按实际天数折算。我在项目里简化成了按月计息,但在功能设计上预留了日利率的扩展点,注释里写清楚了真实场景要按天维度计息。
还款计划表repay_plan的设计:
| 字段 | 说明 |
|---|---|
| id | 主键 |
| loan_id | 借款ID |
| period | 期数 |
| due_date | 应还日期 |
| principal | 应还本金(分) |
| interest | 应还利息(分) |
| status | 0:待还 1:已还 2:逾期 |
3. 投资业务核心流程与撮合机制
3.1 投资业务状态设计与资金冻结逻辑
投资侧的状态机相比借款侧要简单一些,核心就是:待支付、已冻结、已持有、已回款、已转让。其中“已冻结”这个状态是资金类的灵魂,它表示用户的钱已经从余额里扣减、但还没有划拨给借款人,处于一种“我承诺投资但钱还没实际放出去”的中间态。
为什么需要冻结而不是直接扣钱?因为从投资人投标到借款满标之间,存在一个时间窗口。如果用户投了标立刻扣钱,结果这个标最终没满标,那你还得做退款;如果用户投了标不扣钱,那用户的余额可能已经被他用掉了,后端就面临无法履约的兑付风险。冻结是两者之间的折中:钱不能花,但还挂在用户账上,满标后转实扣,流标后解冻。
在项目里,我用一张invest表记录每笔投资单,关键字段如下:
| 字段 | 说明 |
|---|---|
| invest_no | 投资编号 |
| investor_id | 投资人ID |
| loan_id | 关联借款ID |
| amount | 投资金额 |
| rate | 成交利率(年化) |
| status | 0:待支付 1:已冻结 2:已持有 3:已回款 4:已流标 |
| freeze_time | 冻结时间 |
| confirm_time | 确认放款时间 |
资金冻结的动作我在实现上拆成了两步:第一步在account表里把available_balance减掉投资金额,第二部在frozen_balance上加同样金额。账户表保持一种“余额守恒”的约束:total_balance = available_balance + frozen_balance。这个恒等式不在代码里靠逻辑自洽,而是在数据库更新时用UPDATE ... WHERE available_balance >= #{amount}这种带条件的更新,防止并发情况下把余额扣成负数。
3.2 自动投标与撮合匹配的优先级规则
投资业务的撮合,本质上是一个“谁先来谁先得”的过程。项目里投资接口接收一个loan_id和amount,服务端先查这个借款标的的剩余可投金额,如果投资金额小于剩余可投金额,就全部成交;如果大于,就需要考虑是否允许部分投标(实操项目里我限定必须全部投满,因为部分投标会大幅增加债权管理的复杂度,涉及后续每笔投资单独计算还款计划的分摊)。
撮合优先级在真实平台里会考虑用户等级、会员到期时间、投资券是否过期等因素,但在实战项目里我简化成两个规则:时间优先 + 金额优先。时间优先意味着先请求的投资者先获得额度;金额优先则是在超过可投金额时,更好用户的超大额投资会被拆分或拒投。这个规则虽然简单,但配合锁机制就能覆盖绝大多数流程演示。
具体实现上,为了防止多人同时投最后一个标的导致超卖,我用了数据库行锁,伪代码如下:
@Transactional public InvestDTO invest(InvestCommand cmd) { // 锁定借款行,防止并发超投 Loan loan = loanMapper.selectByIdForUpdate(cmd.getLoanId()); // 校验状态必须为投标中 if (loan.getStatus() != LoanStatus.BIDDING) { throw new BizException("该借款标的不在可投状态"); } // 校验投资金额不能超过剩余可投金额 long remain = loan.getAmount() - loan.getInvestedAmount(); if (cmd.getAmount() > remain) { throw new BizException("剩余可投金额不足"); } // 冻结用户资金 accountMapper.freeze(cmd.getInvestorId(), cmd.getAmount()); // 更新已投金额 loanMapper.increaseInvestedAmount(loan.getId(), cmd.getAmount()); // 创建投资单 Invest invest = new Invest(); invest.setInvestNo(GenNoUtil.genInvestNo()); invest.setInvestorId(cmd.getInvestorId()); invest.setLoanId(loan.getId()); invest.setAmount(cmd.getAmount()); invest.setStatus(InvestStatus.FROZEN); investMapper.insert(invest); // 满标判断 checkIfFull(loan); return convert(invest); }selectByIdForUpdate是整段逻辑的定海神针。它意味着所有对这笔借款的操作(发标、投资、满标确认)都会在同一把行锁内串行执行,从根本上杜绝了超卖和重复满标的问题。
3.3 满标确认与自动放款的触发时机
当一个借款标的的已投金额达到借款金额时,系统需要自动触发“满标确认”。我在项目里是在每次投资成功后的同一事务内调用checkIfFull方法,如果满足满标条件,就把借款状态从“投标中”改为“已满标”。
但这里有一个重要区分:满标和放款不是同一步。从“已满标”到“已放款”之间,通常有平台审核环节,在真实业务里这个审核可能是T+0或者T+1。项目里我设计了一个独立接口loanConfirmReleasing,由运营人员或定时任务触发。触发后做的事情包括:把每笔冻结的投资资金转为实际扣款,把总资金划拨到借款人账户,把借款状态更新为“还款中”,同时把每笔投资单的状态改为“已持有”。
这里涉及一笔资金流的转移:
借:平台中间户 - X元 贷:借款人余额 + X元不过在Demo项目里,账户表并没有区分平台中间户,我直接在账户层加了一行注释:真实系统里会有一个“融资人待结算款”科目,用来承接满标后的放款动作。项目为了简洁,放款的实现是批量更新所有投资单的status,同时给借款人的available_balance加钱,给投资人的frozen_balance减钱并增加“持有中资产”字段。这样账户余额的变动是平的,不会有凭空多钱或少钱的问题。
4. 数据库表结构设计与状态一致性保障
4.1 核心表关系与字段落地细节
整个项目一共涉及五张核心表:用户表(account)、借款表(loan)、投资表(invest)、还款计划表(repay_plan)、资金流水表(capital_flow)。前四张负责承载业务状态,最后一张负责承载所有资金变动的明细。
为什么一定要流水表?因为资金系统里任何一笔余额变动都必须是可追溯的。用户看到自己余额多了100元,你要能回答出来这100元是哪个标的、哪笔投资、哪一次还款带来的。没有流水表,余额只是一堆数字,出了纠纷根本说不清。
| 表名 | 核心字段 | 说明 |
|---|---|---|
| account | id, user_id, available_balance, frozen_balance, total_balance | 用户资金账户 |
| loan | id, loan_no, borrower_id, amount, rate, term, repay_type, status | 借款主表 |
| invest | id, invest_no, investor_id, loan_id, amount, status | 投资主表 |
| repay_plan | id, loan_id, period, due_date, principal, interest, status | 还款计划明细 |
| capital_flow | id, user_id, amount, direction, biz_type, biz_id | 资金流水 |
capital_flow里我设计了direction(1=收入,2=支出)和biz_type(1=投资冻结,2=放款,3=还款,4=解冻),biz_id冗余了对应的业务单号。这样设计的好处是:想知道任何一个用户的所有资金往来,一条SQL直接查capital_flow就能看到完整链路,不需要反查业务表,排查问题效率提升一个档次。
4.2 资金安全:事务、锁与余额一致性校验
资金安全是这个项目的生命线。我在多个关键链路都用到了Spring的@Transactional,但这里必须补一句:事务不是银弹,跨表、跨库事务会带来锁粒度大、性能下降的问题,在真正的高并发金融系统里,很多团队会采用最终一致性方案,比如本地消息表加MQ。但在这个实战项目里,单库事务配合行锁已经足够应对演示和中小规模流量。
实操中还有一个隐藏的校验点:账户表的余额更新必须带条件。你以为你写了UPDATE account SET available_balance = available_balance - #{amount} WHERE user_id = #{id}就够了?并发场景下这个SQL会把余额扣成负数。资金项目绝对不允许出现负数余额,所以我在SQL里加了一层AND available_balance >= #{amount}的条件,如果更新行数为0,说明余额不足,直接抛异常回滚。
同样的套路也用在了还款扣款上:
UPDATE account SET available_balance = available_balance - #{amount} WHERE user_id = #{uid} AND available_balance >= #{amount}这种做法在数据库层面做了余额的兜底,代码里就算漏了校验也不会把账搞坏。资金系统的底线是“宁可交易失败,不可账目错乱”,这个思路贯穿了整个项目的所有写操作。
4.3 投资与借款状态同步:字段冗余 vs 实时联查
在设计投资承接待的“剩余可投金额”时,我直接在loan表上冗余了invested_amount字段,每次投资成功后累加对应金额。这样做的好处是查询剩余额度时不需要SUM(invest.amount),一个字段直接返回,性能极好。坏处是存在数据不一致的风险,比如历史数据修复时可能两边对不上。
解决办法是在项目里加了一个对账任务(模拟),每天定时跑脚本对比loan.invested_amount与invest表里所有有效投资的金额总和,不一致时告警。真实金融系统的对账,就是靠这种“字段冗余 + 定期对账”的模式,既保性能又保正确性。
这个思路同样应用在借款状态和投资状态上。借款状态为“还款中”时,所有关联投资的状态应为“已持有”;借款状态为“已结清”时,所有投资应已经回款完成。状态之间靠loan_id维度就可以互相推导,我在代码里补了一个一致性校验工具类,供运营后台手动触发,发现异常状态直接提醒处理。
5. 实战中踩过的坑与排查技巧
做一个资金项目,不怕功能多,就怕隐藏的坑。我把自己实操中真正踩过、并且花了不少时间才排查出来的问题整理了一份速查表,希望能帮后来人省点时间。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 发布借款成功后,列表里看不到 | 状态字段默认为草稿,未置为投标中 | 检查发布接口状态枚举赋值 |
| 投资后用户余额凭空减少了但投资单没产生 | 账户冻结成功,投资单插入失败,事务未回滚 | 检查@Transactional是否失效(代理类调用) |
| 同一笔借款被多次标记满标 | 满标判断在锁外执行,并发投资 | 将满标检查移入投资方法的事务内,让行锁保护 |
| 还款计划分期金额加起来和总金额对不上 | 等额本息每期四舍五入导致尾差 | 最后一期按剩余本息反算 |
| 用户重复点击投资,产生两笔投资单 | 缺少幂等控制 | 投资接口接入request_id防重 |
| 账户余额为负数 | 更新余额时未加余额充足条件 | SQL 追加AND available_balance >= #{amount} |
特别说一下第二个坑,@Transactional失效问题。如果你在同一个类的内部方法A里调用了另一个方法B,B上标了@Transactional,那这个事务是不会生效的,因为Spring代理默认只拦截外部调用,内部this调用绕过代理。我当时就是在服务里写了个doInvest方法,内部自己调了自己的freezeAccount,结果账户扣了钱,投资单没插进去,数据直接烂了。排查过程很痛苦,最后打断点看调用栈才发现是代理问题。解决办法是把事务方法拆到另一个类,或者用AopContext.currentProxy()自调用,最简单也更推荐的做法是:一个事务边界只做一件事,避免方法内部嵌套事务逻辑。
另外一个很实用的排查技巧:在开发阶段给每张业务表都加一个created_at和updated_at,并且建好索引。别说这个无关紧要,我排查某个“用户余额多了500元”的问题时,就是靠查流水表的capital_flow配合created_at时间范围,十分钟就定位到了原因是某次手动补单重复执行了。没有时间字段,你只能全表扫,效率完全不是一回事。
关于并发压测,我自己在本地用Jmeter模拟20个线程同时投同一个标的,测出了一次超卖问题。原因就是我前面说的,满标检查不在锁内。修完之后再跑一轮,20个线程只有最后一个会失败,且最多只有一个能完成满标确认,业务数据完全正确。这种压测技巧建议你起步时就做,别等功能上线再后悔。
6. 从Demo到真实系统的进阶路线
写到这里,基础的借款发布和投资撮合链路已经完整跑通了。但坦白讲,距离真实可运营的金融系统还差着十万八千里。我这里把后续可以继续扩展的方向按优先级列一下,给想深入的朋友一条进阶路线。
第一优先级是还款和催收逻辑的完善。项目里还款计划生成了,但实际的“用户主动还款”“系统自动扣款”“逾期罚息”这几个环节我还只是搭了个壳。真实系统里提前还款怎么重新计算利息、部分还款怎么更新剩余本金、逾期后在哪个时间点开始计罚息,每一个都是独立的计算引擎。
第二优先级是账户体系的多级拆分。目前是一张account表打天下,但真实系统要区分可用余额、冻结余额、在途资金、待结算资金等多个子账户,而且要对每个子账户单独计流水。做得好的系统,余额根本不需要“算”,直接查子账户汇总就能得到。
第三优先级是定时任务与对账体系。放款确认、还款提醒、逾期标记,这些都是靠定时任务触发的,而不是靠用户请求驱动。对账更是资金系统的灵魂:平台账、用户账、第三方支付渠道账,三账要每天对平。这个环节做扎实了,系统才算真正有金融级的样子。
我个人在实际操作中的体会是,这个项目的价值不在于某一个接口写得多巧妙,而在于它提供了一套“可运行的资金链路”参照物。很多文档里的状态图画得再好,都不如你亲手把借款发出、看到投资单冻结、触发满标放款、生成还款计划那一瞬间的成就感。建议你拿到项目之后,不要只看代码,而是按流程自己走一遍:注册两个账号、发布一笔借款、用另一个账号投资、满标、放款、模拟还款,每个环节都打印日志观察状态和余额的变化。这个过程走完,你对资金类业务的理解会上升一个台阶。
项目后续我还会继续迭代,下一版打算把自动还款、债权转让、资产组合推荐这几个方向加上去。到时候数据模型和接口设计上会有不少新决策,我也会同步记下来分享出来。如果你也在做类似的东西,欢迎在评论区聊聊你的设计思路,有些坑一起踩才有意思。