
简介基于Java技术的金融信贷系统设计源码面向金融科技开发者与信贷系统设计人员聚焦客户管理、信贷产品管理、审批流程、合同与风险控制等核心业务环节适合作为信贷管理系统的学习、教学与二次开发基础。压缩包共85个文件包含27个XML配置、27个Java源文件、27个Class文件、2个YAML配置文件及Git忽略与说明文件整体仅139KB目录结构清晰可快速定位后台、客户端、银行接口等模块便于按需查阅。通过源码可了解B/S架构下Spring、Spring MVC、MyBatis等框架的实际整合方式也能参考Maven工程构建与依赖配置的写法对理解信贷业务建模、接口设计和数据持久层开发均有直接帮助。目前已有313人学习下载适合希望在真实项目场景中系统提升Java金融开发能力的读者使用。1. 基于Java技术的金融信贷系统设计源码先别急着看放款接口账账相符才是主线很多拿到“基于Java技术的金融信贷系统设计源码”的人第一反应是打开放款接口看代码。实际做过的人都知道信贷系统风险最高的地方不在放款动作而在还款计划生成、额度占用和日终对账。这三处任何一处差一分钱轻则报表不平重则资金损失。所谓设计源码交付的不只是一堆能编译的Java文件还包括表结构设计、状态机定义和模块拆分说明你要做的第一件事不是跑起来看界面而是确认它能否串通进件、审批、放款、还款这条完整资金闭环。下面按我实际做过的方式讲对三类人最有用从普通Java开发转信贷方向的工程师、要做课程设计或毕设的在校生、以及小团队想自建信贷系统的技术负责人。读完你能判断这份源码能省多少事、要改哪里、坑在哪。2. 信贷系统的核心业务模块与数据模型设计从进件到还款计划表结构怎么定一个信贷系统源码真正值钱的部分往往不是Controller和Service的调用链而是底层那几张表怎么拆。老工程师拿到新项目先看表结构因为表结构决定了业务边界申请、授信、借款、还款计划如果全塞在一张表里后面每加一个产品都要动核心表根本不敢重构。这一章把数据模型讲透再给一段可复用的实体类建表思路。2.1 客户、申请、授信、借款、还款计划五张主表的职责边界与关键字段先明确为什么拆成五张主表借贷生命周期里客户、申请、授信、借款、还款计划是五个不同节奏的实体。客户资料基本不变申请可以多次提交授信可能一次审批多次提款一笔借款会拆成多期还款计划。把它们拆开状态才能独立流转。表名关键字段职责边界cust_infocust_id, cert_type, cert_no, cust_name, mobile, risk_level客户主档一份客户一条记录loan_applyapply_id, cust_id, product_code, apply_amt, term, rate, apply_status, apply_time进件申请一次申请一条记录credit_grantcredit_id, cust_id, credit_amt, used_amt, avail_amt, expire_time, credit_status授信额度审批通过后生成loan_acctloan_id, credit_id, loan_amt, loan_balance, loan_status, disburse_time, due_time借款借据一次放款一条记录repay_planplan_id, loan_id, period_no, due_date, principal, interest, actual_paid, paid_status每期还款计划随放款批量生成这种拆分下loan_acct 通过 credit_id 引用授信repay_plan 通过 loan_id 引用借据关联方向清晰对账时能顺着引用链还原资金全貌。很多课程设计案例源码做不到这一步它们常把申请、授信、借款合并成一张大状态表最后对账时根本无法还原资金流水。金额字段统一 decimal(18,2)主键用雪花ID而不是自增ID避免批量导入或分库时主键冲突。2.2 用 MyBatis-Plus 根据 Java 实体类生成建表 SQL实体类注解与 DDL 的转换边界不少人搜“mybatisplus根据java实体类生成创建表的sql语句”以为MyBatis-Plus有现成的一键工具。实际上它没有开箱即用的“实体类转建表SQL”命令社区常见做法是基于 TableInfoHelper 机制写一个小工具。原理是读取实体类上的 TableName、TableField、TableId 等注解反射拼出 CREATE TABLE 语句。我一般不会在生产环境全自动执行而是先生成 SQL 脚本人工确认字段类型和索引后再执行。import com.baomidou.mybatisplus.core.metadata.TableInfo; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.metadata.TableFieldInfo; import java.math.BigDecimal; import java.time.LocalDateTime; // 基于 TableInfoHelper 生成 CREATE TABLE 的简化工具 public class DdlBuilder { public static String build(Class? entityClass) { TableInfo tableInfo TableInfoHelper.getTableInfo(entityClass); StringBuilder sb new StringBuilder(); sb.append(CREATE TABLE IF NOT EXISTS ) .append(tableInfo.getTableName()).append( (\n); // 主键ASSIGN_ID 雪花ID在实体类中是 Long这里映射为 bigint sb.append( ).append(tableInfo.getKeyColumn()) .append( bigint NOT NULL COMMENT 主键,\n); // 普通字段按 Java 类型映射到 MySQL 类型 for (TableFieldInfo field : tableInfo.getFieldList()) { String colType mapType(field.getPropertyType()); sb.append( ).append(field.getColumn()) .append( ).append(colType) .append( DEFAULT NULL COMMENT ) .append(field.getProperty().getName()).append(,\n); } // 逻辑删除字段映射为 tinyint查询会自动追加 deleted 0 if (tableInfo.isWithLogicDelete()) { sb.append( deleted tinyint DEFAULT 0 COMMENT 逻辑删除,\n); } sb.append( PRIMARY KEY ().append(tableInfo.getKeyColumn()).append()\n); sb.append() ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT) .append(tableInfo.getTableName()).append(;\n); return sb.toString(); } private static String mapType(Class? type) { if (type Long.class || type Integer.class) return bigint; if (type BigDecimal.class) return decimal(18,2); if (type LocalDateTime.class) return datetime; if (type Boolean.class) return tinyint(1); return varchar(64); } }逻辑说明TableInfoHelper.getTableInfo 会缓存实体元数据因此不要每次请求都调用启动时生成一次即可mapType 是核心映射函数BigDecimal 默认给 decimal(18,2)LocalDateTime 给 datetime。用这个工具生成的只是表和普通字段联合唯一索引、普通索引、初始化数据都不会生成要补一个手工SQL文件。这也是我坚持“生成脚本、人工确认”的原因——实体类上的注解写得不完整时全自动执行会直接把错误字段类型建到测试库里。注意代码里主键列名默认是 id如果你的实体类 TableId(value cust_id)取的是 valuedecimal(18,2) 能覆盖绝大多数信贷金额字段但利率字段建议单独调整精度。把这条链路理解透之后再回去读 mybatis 源码里 TableInfoHelper 的字段扫描逻辑基本就能看懂 MyBatis-Plus 从实体类到数据库列的映射规则面试时被问到“MyBatis-Plus 是怎么把实体映射成表的”也可以拿这段工具代码做底稿。2.3 状态机字段与流水表为什么信贷系统不能只存“当前状态”单表上放一个 status 字段再常见不过但信贷场景里状态本身就是业务数据客户什么时候发起申请、审批人是谁、放款通道返回什么、还款是否失败这些都需要审计。常见做法是“状态字段 状态流水表”配套。状态流水表记录每一次变更的 from_status、to_status、operator、remark、create_time让对账能回答“这笔借据是怎么变成逾期的”。// 借据状态机只允许合法流转非法流转直接抛异常 public enum LoanStatus { DRAFT(草稿, Arrays.asList()), APPROVING(审批中, Arrays.asList(DRAFT)), APPROVED(审批通过, Arrays.asList(APPROVING)), DISBURSING(放款中, Arrays.asList(APPROVED)), ACTIVE(还款中, Arrays.asList(DISBURSING)), OVERDUE(逾期, Arrays.asList(ACTIVE)), SETTLED(结清, Arrays.asList(ACTIVE, OVERDUE, DISBURSING)), CANCELED(已作废, Arrays.asList(DRAFT, APPROVING, APPROVED)); private final String desc; private final ListLoanStatus allowedPrev; public boolean canTransitFrom(LoanStatus prev) { return allowedPrev.contains(prev); } }这个枚举把状态流转规则收敛到一处Service 层不需要到处写 if 判断。状态校验放在事务入口并发请求同时改状态时只有满足前置状态的那条能通过另一个直接报“状态已变更”。状态流水表在每次 update 前插入一条历史记录保证任何时间点都能回溯。3. 信贷核心流程的Java实现进件、审批、放款三个关键节点的并发与事务控制流程类功能是信贷源码里最容易被看出功力的地方。进件、审批、放款三个节点各有各的坑进件要防产品规则混乱审批要防状态乱跳放款要防资金重复扣划。这一章把三个节点的常见实现方式拆开讲。3.1 进件与审批用策略模式替代满屏 if-else 的产品差异不同信贷产品在费率、期限、审批规则上天然有差异。最常见的反面案例是在审批 Service 里写几十行 if (productCode.equals(xxx))新产品上线就要改老代码测试回归范围一次比一次大。常见做法是定义策略接口每种产品一套实现类通过工厂按 productCode 取出对应策略。// 策略接口让产品差异收敛到各自实现类而不是在 Service 里堆 if-else public interface CreditStrategy { String productCode(); boolean preCheck(LoanApply apply); // 审批通过后按产品策略计算授信金额 BigDecimal calcApprovedCredit(LoanApply apply); } // 工厂注册所有策略按产品码取 Component public class CreditStrategyFactory { private final MapString, CreditStrategy map new HashMap(); public CreditStrategyFactory(ListCreditStrategy strategies) { for (CreditStrategy s : strategies) { map.put(s.productCode(), s); } } public CreditStrategy get(String productCode) { CreditStrategy s map.get(productCode); if (s null) { throw new IllegalArgumentException(不支持的信贷产品: productCode); } return s; } }逻辑说明Spring 会把所有 CreditStrategy 实现类注入到构造方法的 List 里工厂在启动时完成注册。新增产品只需要新增一个实现类审批 Service 不用改。如果产品差异只在利率、期限等参数上不要为每个产品写一个类应该把这些参数放进 product_config 表策略只在行为真正不同时才新建实现类。参数说明productCode 是策略的唯一 KeypreCheck 做硬性校验比如额度上限、年龄限制calcApprovedCredit 输出授信金额后续额度占用、放款都依赖这个值。3.2 放款扣账与还款计划生成本地事务的粒度决定对账是否好做放款是信贷系统里最容易出资金事故的动作。常见做法是放款方法只做三件事校验借据状态、生成还款计划、更新借据状态。真正扣款通道的调用不能放在这个大事务里否则通道超时会长时间占用数据库连接还会拖垮整个服务。Service public class DisburseService { Transactional(rollbackFor Exception.class) public Long doDisburse(DisburseReq req) { // 1. 校验借据状态必须是 DISBURSING防止重复放款 LoanAcct acct loanAcctMapper.selectById(req.getLoanId()); if (acct.getStatus() ! LoanStatus.DISBURSING) { throw new BizException(借据状态不允许放款); } // 2. 生成还款计划纯函数不依赖 Spring 上下文方便单测 ListRepayPlan plans repayPlanBuilder.build( acct.getLoanAmt(), acct.getRate(), acct.getTerm(), acct.getDisburseDate()); for (RepayPlan p : plans) { repayPlanMapper.insert(p); } // 3. 更新借据状态为 ACTIVE acct.setStatus(LoanStatus.ACTIVE); loanAcctMapper.updateById(acct); return acct.getLoanId(); } }逻辑说明这个方法被 Spring 事务包裹任何一步 SQL 失败都会整体回滚。但外部资金扣款调用绝不能放在这个方法里常见做法是事务提交后发一个 Spring 事件由监听器异步调通道接口。这样本地账务和外部通道解耦通道超时只影响异步监听器不会锁住事务连接。参数说明rollbackFor Exception.class 表示所有异常都触发回滚而不是默认的 RuntimeException借据状态在方法入口用枚举比较比用字符串比较更安全还款计划生成器设计成纯函数不注入 Mapper是刻意的——它只依赖入参单元测试不用起 Spring 容器。3.3 额度计算与额度占用用 Redis Lua 把并发抢额度变成串行授信额度是共享资源。两个申请同时提款时如果直接读数据库的 avail_amt 做判断会超卖。常见做法是 Redis 里缓存可用额度扣减时用 Lua 脚本保证原子性。数据库乐观锁也能做但 Redis Lua 在进件高峰下吞吐更高额度不足时返回失败也快。-- key: 授信额度键例如 credit:avail:{creditId} -- argv[1]: 本次申请占用金额单位分 -- 返回值 1 表示扣减成功0 表示额度不足 local avail tonumber(redis.call(GET, KEYS[1])) local reqAmt tonumber(ARGV[1]) if avail false or avail reqAmt then return 0 end redis.call(DECRBY, KEYS[1], reqAmt) return 1// 申请占用额度的原子操作amount 统一转为“分”再传入 public boolean tryOccupy(Long creditId, BigDecimal amt) { String key credit:avail: creditId; Long result stringRedisTemplate.execute( new DefaultRedisScript(LUA_OCCUPY, Long.class), Collections.singletonList(key), amt.movePointRight(2).toPlainString() // 元转分避免浮点误差 ); return Long.valueOf(1).equals(result); }逻辑说明Lua 脚本在 Redis 里是原子执行的GET 和 DECRBY 之间不会插入其他命令。金额统一用“分”为单位避免 BigDecimal 序列化到 Redis 后精度丢失。扣减成功不代表放款成功如果后面的本地事务回滚必须在事务提交后的监听器里补偿回额度。这个补偿环节很多源码会漏漏掉的结果是用户额度被白白占用。参数说明额度 key 要设置过期时间防止授信过期后 key 永驻movePointRight(2) 比 multiply(100) 语义更清晰也避免 BigDecimal 乘法产生不必要的小数位Redis 版本要 2.6 以上才支持 Lua 脚本。这正好是 java 面试题里常问的“如何防止并发超卖”的落地版本。还款计划与资金对账等额本息、批量代扣和T1对账的三个隐蔽坑放款上线相对容易难的是放款之后每个月跑还款计划和对账。这个环节的源码问题往往不是逻辑复杂而是细节精度和状态闭环没做好。下面三个坑是信贷系统里最常见的翻车点我在生产环境都处理过写出来供你对照排查。4.1 等额本息计算公式与 BigDecimal 精度控制等额本息的每期还款额公式是本金 × 月利率 × (1 月利率)^期数 ÷ ((1 月利率)^期数 - 1)。用 BigDecimal 计算时最容易出错的是月利率除不尽、每期本金四舍五入误差累积。常见做法是月利率保留 8 位小数参与运算每期利息按剩余本金计算最后一期本金用“倒挤”方式修正。// 等额本息还款计划生成最后一期做“倒挤”修正 public ListRepayPlan buildEqualPrincipalAndInterest(BigDecimal principal, BigDecimal annualRate, int term, LocalDate disburseDate) { // 月利率年化利率 / 12保留 8 位小数参与中间运算 BigDecimal monthlyRate annualRate.divide(BigDecimal.valueOf(12), 8, RoundingMode.HALF_UP); // 每期应还总额 principal * r * (1r)^n / ((1r)^n - 1) BigDecimal pow monthlyRate.add(BigDecimal.ONE).pow(term); BigDecimal numerator principal.multiply(monthlyRate).multiply(pow); BigDecimal denominator pow.subtract(BigDecimal.ONE); BigDecimal monthlyPay numerator.divide(denominator, 2, RoundingMode.HALF_UP); ListRepayPlan plans new ArrayList(); BigDecimal remaining principal; for (int i 1; i term; i) { // 当期利息 剩余本金 × 月利率保留 2 位 BigDecimal interest remaining.multiply(monthlyRate).setScale(2, RoundingMode.HALF_UP); BigDecimal principalPart monthlyPay.subtract(interest); if (i term) { // 最后一期本金直接等于剩余本金避免四舍五入误差滚到末期 principalPart remaining; } remaining remaining.subtract(principalPart); // 组装 RepayPlan 后加入 plans 列表这里省略 set 语句 } return plans; }逻辑说明monthlyRate 取 8 位小数是为了让每期利息计算尽量接近真实数学结果最终落库金额统一保留 2 位。最后一期不按“月供 - 利息”计算本金而是直接用剩余本金保证所有期本金之和严格等于放款金额。这个“倒挤”是血泪经验很多源码没做跑三个月账就会差几块钱。参数说明RoundingMode.HALF_UP 是金融领域最常用的四舍五入不要用 DOWN每期少一分钱累计到最后一期本金对不上提前结清时要另算违约金和当期利息不能直接把剩余本金当结清金额。4.2 批量代扣批次状态与逐笔明细的对账闭环批量代扣是信贷系统还款的主要通道。常见设计是一个批次表记录整体进度一个明细表记录每笔扣款结果。批次表与明细表必须分离通道先返回受理成功实际扣款结果在日终回盘文件中到达只有明细表能回答“某笔到底扣没扣”。批次表字段说明明细表字段说明batch_id批次号detail_id明细IDbatch_date代扣日期batch_id所属批次total_count总笔数loan_id借款借据IDsuccess_count成功笔数due_date到期日fail_count失败笔数total_amt本期应还金额batch_status批次状态request_no全局幂等键channel_code通道编码deduct_status扣款状态create_time创建时间retry_count重试次数批次状态和明细状态分开后日终对账以明细为准。超时明细的处理方法是先查通道再决定重发或标记失败不能盲目重发。下面是一段简化版处理逻辑// 超时明细的处理先查通道再决定重发还是标记失败 public void handleTimeoutDetail(Long detailId) { DeductionDetail detail deductionDetailMapper.selectById(detailId); if (!TIMEOUT.equals(detail.getDeductStatus())) { return; } // 调通道查询真实状态返回 SUCCESS / FAIL / UNKNOWN ChannelResp resp channelClient.queryByRequestNo(detail.getRequestNo()); if (resp.isSuccess()) { detail.setDeductStatus(SUCCESS); } else if (resp.isFail()) { detail.setDeductStatus(FAIL); } else { // UNKNOWN 时重试次数 1限制最大重试次数避免无限重发 detail.setRetryCount(detail.getRetryCount() 1); if (detail.getRetryCount() MAX_RETRY) { detail.setDeductStatus(FAIL); } } deductionDetailMapper.updateById(detail); }逻辑说明任何代扣渠道都可能出现“结果未知”的状态处理方式不是直接重发否则用户可能被扣两次。request_no 是每笔明细生成时指定的全局唯一键明细表上要建唯一索引。重试时复用原 request_no通道才能识别是同一笔请求。4.3 日终跑批为什么对账文件要在“状态快照”上做日终跑批生成对账文件时如果直接实时查 repay_plan 表跑批期间一旦有还款发生文件前后就会不一致。常见做法是先生成一份当日待还快照表基于快照生成文件和后续对账。快照表主键用 plan_id重复生成时先按批次清空再插入保证幂等。快照表里不仅要记应还金额还要记当时的 paid_status。这样对账才能发现“文件生成了但还款已经发生”的差异。生成文件时按到期日排序金额汇总由数据库完成不要在 Java 内存里用 double 累加。跑批进度要落库用 batch 表的 batch_status 记录 RUNNING / SUCCESS / FAIL一旦中途失败能从断点恢复而不是整批重跑。5. 信贷系统避坑权限、软删除、金额精度、慢SQL的排查与修复信贷源码拿到手先别急着跑功能。我一般先按下面五类问题做一遍代码审查每一条都是真实线上排过的坑按“现象 → 原因 → 解决”列出来。5.1 金额用 double 计算还款计划差一分钱现象系统跑半年后总账和明细差 0.01 元日终对账永远不平。原因double 是二进制浮点数0.1 在内存里不是精确值累加后被舍入。解决金额一律用 BigDecimal数据库用 decimal(18,2)除法必须指定 scale 和 RoundingMode所有金额字段的 getter 返回 BigDecimal禁止用 Double 接收前端参数。这条血泪经验值一套对账程序新人写的金额计算代码我基本都会重点 review。5.2 行级权限缺失客户经理能查全量客户现象A 客户经理登录后能搜到其他客户经理名下的所有客户申请列表。原因查询 SQL 没有拼机构或客户归属条件只做了登录校验没做数据权限控制。解决写一个 MyBatis 拦截器在 SQL 解析阶段根据当前登录用户注入机构条件而不是在每个 Mapper 里手写 where。下面是一个简化版的拦截器逻辑// MyBatis 拦截器自动追加数据权限条件避免业务代码漏加 Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class DataPermissionInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 从上下文拿到当前用户所属机构 String orgId UserContext.getCurrentOrgId(); if (orgId null) { return invocation.proceed(); } // 改写 BoundSql在原有 SQL 后追加 AND org_id ? // 具体实现要处理 JOIN、子查询、分页 SQL 等边界这里省略 return invocation.proceed(); } }这个做法的好处是业务代码不用每个查询都记得拼条件漏一次就是数据泄露。注意拦截器要能识别哪些表属于受控表哪些是字典表否则会把字典表的查询也拦截掉。5.3 软删除字段与唯一索引冲突现象重复创建相同证件号的客户时第二次插入报 Duplicate entry。原因唯一索引建在 cert_no 上但第一条客户被逻辑删除了deleted1 的记录仍然占着索引。解决唯一索引改成 (cert_no, deleted)让不同删除状态的记录可以共存另一种做法是删除时把 cert_no 改成一个带时间戳后缀的值。第二种做法更彻底但会污染真实数据我推荐第一种注意 deleted 字段要设置默认值 0。5.4 MyBatis-Plus 生成建表 SQL 时字段类型与预期不符现象用第 2.2 节的工具生成 DDL执行后金额字段变成 decimal(10,0)日期字段没有精度。原因mapType 的默认映射不够智能实体属性又没有用注解显式声明精度。解决不要在工具里写死映射而是在实体类字段上用 TableField 注解补充精度生成 DDL 后人工检查金额和日期两列。这个坑最容易出现在测试环境因为测试数据量小字段精度问题不容易暴露。5.5 批量代扣重复扣款用户被扣两次现象代扣通道超时定时任务重发用户银行卡被扣两次。原因本地没有建立全局幂等键重发时生成了新的 request_no。解决每笔明细生成时分配 request_no并在明细表上建唯一索引重试时复用原 request_no先查通道再重发。流程上还要加一道保护同一批次的同一笔明细最多只允许人工发起一次强制重发。这条是我见过最常见的资金事故处理不好就是客诉。6. 验证与进阶用最小还款计划跑通全流程再考虑接入资金通道拿到任何信贷源码我第一件事是把还款计划生成器隔离出来做单测。这个习惯救过我很多次还款计划是整个信贷系统的账务核心如果它在精度上有问题后面接什么通道都会对不上账。最小验证方案是写一个 JUnit 测试本金 10000 元、年化 12%、12 期断言每期应还总额稳定、本金合计等于 10000、最后一期本息和与剩余本金对齐。// 最小闭环验证跑通一笔贷款的还款计划终态 public class CreditLoopTest { Test public void validateRepayPlan() { ListRepayPlan plans repayPlanBuilder.build( new BigDecimal(10000.00), new BigDecimal(12.00), 12, LocalDate.now()); // 所有期本金之和必须等于放款金额 BigDecimal principalSum plans.stream() .map(RepayPlan::getPrincipal) .reduce(BigDecimal.ZERO, BigDecimal::add); assertEquals(0, new BigDecimal(10000.00).compareTo(principalSum)); // 最后一期本金大于 0且本息和不超过剩余本金加当期利息 RepayPlan last plans.get(plans.size() - 1); assertTrue(last.getPrincipal().compareTo(BigDecimal.ZERO) 0); assertTrue(last.getPrincipal().add(last.getInterest()) .compareTo(new BigDecimal(10000.00)) 0); } }如果这类核心账务验证通过再逐步接入资金通道。资金通道的优先级低于内部对账不要先接通道再补账务。我自己的习惯是接通道前必须把四个点列成验收清单幂等键是否到位、退款能否原路返回、冲正流程是否存在、T1 对账文件是否有字段映射说明。四项都确认了才把通道切到生产。我曾经在接入一个资金通道时只把重心放在放款接口上结果对账文件第一天就差了 0.03 元查了半天才发现是还款计划倒数第二期利息被多舍入了一次。后来我把所有账务计算都改成独立单测接任何信贷项目都先验证还款计划这个“账务黑匣子”再谈通道接入。希望这个顺序对你也有用能帮你少走一段弯路。本文还有配套的精品资源点击获取