☰
一诺银华催收业务系统:业务中台驱动贷后案件全流程管理
2026/10/19 2:53:16 网站建设 项目流程

简介:催收业务系统详细设计说明及配套工程文件,主要面向Java后端开发、系统分析与催收行业软件设计人员。文档细致描述了系统概况、功能需求、技术需求、实现环境及实现技术,明确了软件的功能、性能与有效性要求,并对案件管理、催收执行、名单导出等核心业务模块进行了定义,能够帮助读者理清从业务建模到代码实现的完整链路。压缩包共749个文件、约25.27MB,以png/gif界面截图、java/jsp/js/css前后端源码、class编译文件及jar依赖库为主,同时附带sql/db数据库脚本;案例中的CaseInfoBiz、EmployeeAction、DunExportBiz等类可直接对照理解业务流程,png图片则直观展示了系统界面与页面设计。目前已有3417人学习下载,适合需要借鉴成熟催收系统设计或进行二次开发的中高级开发人员,是一份兼顾设计规范与实践细节的完整参考资料。

1. 一诺银华催收业务系统:把催收作业从 Excel 表格里解放出来的业务中台

一诺银华催收业务系统,最早是某信贷团队内部为了提高贷后回收率自研的一套业务中台。催收员每天要对着几十个逾期案件,查还款进度、翻通话记录、安排外访任务,这套系统把案件分派、电话催收、外访打卡、减免审批、还款登记和业绩统计串成一条完整业务链,从案件进入催收池到结案归档全程线上化流转。适合两类人研究:一类是信贷机构或催收团队的运营人员,想找一套能直接落地的作业系统;另一类是金融业务开发者,想参考案件状态机、并发分派锁、减免审批这类典型模块的实现。

2. 技术架构:为什么用经典三层加独立定时任务模块

2.1 技术选型与整体分层

催收系统的业务特点决定了它的技术选型:业务链路长但瞬时并发不高,数据敏感且状态流转复杂。它不像电商秒杀需要扛高 QPS,但每个案件从分配到结案要经过多次状态变更和金额计算,对事务一致性和操作审计的要求反而更高。所以我见过的同类系统大多不追新框架,后端用 Spring Boot 2.x + MyBatis + MySQL 的经典组合,Redis 用来做分布式锁和热点缓存,前端用 Vue 2 + Element UI 管理端组件库,任务调度交给 Spring 自带定时任务能力,只有特别复杂的分派规则才引入额外任务框架。这套组合在金融业务系统里足够稳,招人也容易。

整体上分成四层,Controller 只做参数校验和路由,Service 层承载业务规则和事务边界,Mapper 层管数据访问,中间夹一个 common 模块放状态枚举、常量、统一返回体。前端走标准的 api 到 store 到 view 结构。另外单独拆了一个 timer 定时任务模块,跑每晚的到期案件自动进池、每日日报汇总这类批量任务,核心业务和任务代码物理隔离,避免定时任务把接口服务的内存和数据库连接池拖垮。

# application-dev.yml 关键配置片段 server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/yn_case?useUnicode=true&characterEncoding=utf8 username: root password: your_password redis: host: localhost port: 6379 mybatis-plus: mapper-locations: classpath*:mapper/*.xml global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

datasource 连接的是 yn_case 库,字符集必须显式指定 utf8,否则导入中文案件名会乱码。Redis 端口保持默认 6379,如果本地被占用要改成自己的实例端口。mapper-locations 指到 classpath 下的 XML 目录,所有手写 SQL 都放那里。逻辑删除字段 deleted 是全局统一配置,后面避坑章节会专门讲这个为什么重要。

不是没考虑过微服务,但这类系统的内部用户量通常几十到几百人,案件量上百万,单日新增不过几千条,拆成微服务纯属给自己找麻烦——拆了服务就要处理分布式事务,案件状态变更跨库回滚的成本很高。单体应用配一个独立 timer 进程,已经是这个体量下最合适的选择。如果后续消息通知、外呼对接变多,再顺着业务边界抽模块也不迟。

2.2 目录结构与核心表设计

拿到源码包后先看目录,项目是前后端分离的 maven 聚合工程:

yn-yinhua/ ├── yn-server/ # 后端服务(Spring Boot) │ ├── src/main/java/com/yh/ │ │ ├── controller/ # 接口层:案件、催收、外访、减免、报表 │ │ ├── service/ # 业务层:分派策略、催收登记、审批流 │ │ ├── mapper/ # MyBatis 数据访问 │ │ ├── entity/ # 数据库实体 │ │ ├── common/ # 枚举、常量、Result 统一返回体 │ │ └── config/ # Redis、MyBatis、线程池配置 │ └── src/main/resources/ │ ├── mapper/ # 手写 SQL XML │ ├── sql/init.sql # 建库建表 + 种子数据 │ └── application.yml # 主配置 ├── yn-web/ # 前端(Vue2 + Element UI) └── yn-timer/ # 定时任务模块

yn-server 是核心,yn-timer 单独部署也行,两者共用同一个数据库。sql/init.sql 是入口,先执行它再启动服务。

数据库大约有十几张表,核心的几张如下表。case_info 是主表,几乎每个模块都要引用它的案件状态;phone_call_record 每天插入量最大,按时间分了表;减免申请和承诺还款走独立流程,保证审批历史可追溯。

表名职责关键字段
case_info案件主表case_no, borrower_name, overdue_days, collector_id, status
collector_info催收员信息collector_no, name, dept_id, skill_level
phone_call_record通话记录case_id, call_result, call_duration, record_url
visit_task外访任务case_id, visitor_id, address, lat, lng, visit_status
reduce_apply减免申请case_id, apply_rate, audit_status, auditor_id
promise_pay承诺还款case_id, promise_date, pay_status
repay_record还款登记case_id, amount, repay_type, operator_id

case_info 的 status 是整个系统的状态核心,流转关系是:0 待分派、1 已分派、2 催收中、3 还款中、4 外访中、5 已结案,还有 6 已减免这种平行状态。新手改这块最容易翻车,改状态时一定要带上 where status = 旧值 条件,保证状态机的每一步都是唯一的。这个状态机不仅是逻辑约束,也直接对应工单流转和业绩统计口径。

3. 核心模块拆解:案件从进池到结案的完整链路

3.1 案件导入与自动分派

案件进来的途径一般有两条:Excel 批量导入和贷后系统接口推送。Excel 用 Apache POI 解析,逐行读取后封装成导入对象,先按借款合同号和到期日去重,再批量插入 case_info。接口推送走一个 importCase 接口,两者最终都调同一个导入服务,把案件状态置为 0,等待分派。

自动分派是每天定时执行的。晚上十点,timer 模块把所有待分派案件捞出来,按机构、账龄和催收员在途量打分排序。这里有个典型的并发问题:两个催收员同时点领取时,同一案件可能被抢两次。我的处理方式是 Redis 分布式锁加乐观更新双保险,代码里能看到完整写法。

// CaseAssignService.assignCase() 案件分派核心逻辑 public AssignResult assignCase(AssignRequest request) { String lockKey = "case:assign:" + request.getCaseId(); RLock lock = redisClient.getLock(lockKey); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { CaseInfo caseInfo = caseMapper.selectById(request.getCaseId()); // 状态不是待分派,直接返回,防止重复领取 if (caseInfo == null || caseInfo.getStatus() != CASE_STATUS_UNTREATED) { return AssignResult.fail("案件不存在或已被领取"); } Collector target = pickCollector(request.getStrategy(), request.getDeptId(), caseInfo.getOverdueDays()); if (target == null) { return AssignResult.fail("当前策略下没有可分配的催收员"); } caseInfo.setCollectorId(target.getCollectorId()); caseInfo.setStatus(CASE_STATUS_DISTRIBUTED); caseInfo.setAssignTime(new Date()); caseMapper.updateById(caseInfo); return AssignResult.success(caseInfo); } } catch (Exception e) { log.error("案件分派异常 caseId={}", request.getCaseId(), e); return AssignResult.fail("系统繁忙,请重试"); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } return AssignResult.fail("获取分派锁超时,请重试"); }

这段逻辑有三个要点。lockKey 按 caseId 加锁,保证同一件案件同时只能被一个线程处理;tryLock 设置 3 秒等待、10 秒自动释放,防止锁一直占着。拿到锁之后先查状态再更新,状态不是待分派就直接失败,把并发窗口堵死。pickCollector 是策略方法,入参 strategy 是分派策略编码,deptId 限定部门,overdueDays 决定优先让谁接,目前内置了按在途量最少和按技能等级优先两种策略。

这里有个常见的误用:直接用 synchronized 锁。如果系统部署了多个实例,synchronized 只在单机生效,分派还是会并发出错。Redis 分布式锁才能在多实例下保证互斥,这个坑在后续避坑章节还会再展开。

3.2 电话催收与通话结果登记

催收员进案件详情页后可以直接点外呼,系统通过对接的呼叫中心拉起电话,通话结束后把通话时长、录音地址传回后端。这个环节尽量少让催收员手工填,录音和时长自动带出,人工只负责选一个催收结果。

催收结果分了七种枚举:承诺还款、协商中、未联系上、拒绝还款、空号或停机、转案、其他。每种结果都联动案件状态和后续任务,比如选未联系上会累加未联系次数,超过三次自动把案件推进外访队列。

// PhoneCollectService.savePhoneCall() 保存通话记录并触发后续动作 @Transactional(rollbackFor = Exception.class) public void savePhoneCall(PhoneCallRecord record) { record.setRecordId(UUID.randomUUID().toString()); phoneCallMapper.insert(record); // 先落库,后续流程失败可回滚 String callResult = record.getCallResult(); if ("agreePromise".equals(callResult)) { PromisePay promise = new PromisePay(); promise.setCaseId(record.getCaseId()); promise.setPromiseDate(record.getPromiseDate()); promise.setPromiseAmount(record.getPromiseAmount()); promise.setPayStatus(PAY_STATUS_WAIT); promiseMapper.insert(promise); // 给案件挂一个承诺还款到期提醒 todoQueue.push(record.getCaseId(), "承诺还款到期提醒", record.getPromiseDate()); } else if ("noContact".equals(callResult)) { int missCount = countMissByCaseId(record.getCaseId()); if (missCount >= 3) { caseInfoMapper.updateStatus(record.getCaseId(), CASE_STATUS_WAIT_VISIT); } } }

@Transactional(rollbackFor = Exception.class) 保证通话记录、承诺还款、案件状态三步要么全成功,要么全失败,避免出现通话记录了但承诺还款没建的账实不符。countMissByCaseId 统计的是该案件最近一轮未联系上的次数,达到三次就转外访。这里只截了两种结果的落库逻辑,实际代码里七种枚举各有一个分支,每种分支都对应一个明确的状态动作。

3.3 外访任务与现场签到

外访任务从上一节的未联系上自动转入,也可以由组长手动创建。任务创建后要经过一次审批,确认外访地址和催收员排期,审批通过后催收员在外访 App 上执行任务。这个创建与生效分离的设计值得保留,因为外访涉及外出资源调度,任务直接生效容易打乱排期,也缺少风险把控。

外访的要点是现场验证。催收员到达债务人地址后,先定位后拍照,App 把经纬度、精度、时间戳、现场照片四样东西一起提交到后端,后端校验时间和距离,防止没到现场却打外访卡的虚假签到。

// POST /api/visit/checkin 请求体示例 { "visitTaskId": 20240815001, "lat": 31.2304, "lng": 121.4737, "accuracy": 21.5, "photoUrls": ["/oss/visit/20240815/xxx.jpg"], "checkinTime": "2024-08-15 10:23:44" }

checkinTime 由服务端生成,不信任客户端上传的时间,防止人为改时间。accuracy 表示定位精度,单位是米,我一般设定超过 50 米直接要求重新定位。photoUrls 现场照片至少传一张,后台会做影像比对,比对结果进入质检中心,这条链路在外访纠纷时能拿出完整的证据链。

3.4 减免审批与还款登记

催收过程中经常遇到债务人有还款意愿但能力不足的情况,这时催收员可以申请减免部分利息或罚息。减免申请不是催收员自己拍板,要经过组长、风险合规两层审批,每一层的审批记录都留在 reduce_apply 表里,后续审计能查出是谁批的、批了多少。

// ReduceApplyService.auditReduceApply() 减免审批核心方法 public AuditResult auditReduceApply(Long applyId, BigDecimal applyRate, Long auditorId) { ReduceApply apply = reduceApplyMapper.selectById(applyId); if (apply == null || apply.getAuditStatus() != AUDIT_STATUS_WAIT) { return AuditResult.fail("申请不存在或已被审批"); } // 审批人给的减免比例不能超过申请比例 if (applyRate.compareTo(apply.getApplyRate()) > 0) { return AuditResult.fail("审批减免比例不可超过申请比例"); } apply.setAuditRate(applyRate); apply.setAuditStatus(AUDIT_STATUS_PASS); apply.setAuditorId(auditorId); apply.setAuditTime(new Date()); reduceApplyMapper.updateById(apply); // 同步重算该案件的剩余应还金额 ledgerMapper.recalculate(apply.getCaseId(), applyRate); return AuditResult.success("审批通过"); }

compareTo 这一步是金额类业务的常见校验,BigDecimal 不能用 equals 直接比,否则 2.0 和 2.00 会被判定为不相等。审批通过后 recalculate 会重算案件剩余应还金额,把减免掉的金额从台账里扣除。还款登记发生在债务人实际打款后,登记时用 SELECT FOR UPDATE 锁住案件台账行,再更新已还金额和案件状态,防止和减免审批并发时算错余额。

4. 部署与运行:从空环境到第一次跑通

4.1 环境准备与数据库初始化

运行这套系统需要的东西不多:JDK 1.8 或 11,MySQL 5.7 及以上,Redis 本地起一个默认端口的实例就行,Node.js 10 以上用来跑前端。后端需要 Maven 3.5 以上,前端用 npm。全部装好后,第一步不是急着启动,而是先初始化数据库。

# 进入项目根目录后执行建库建表 mysql -uroot -p < yn-server/src/main/resources/sql/init.sql # 确认表是否建成功 mysql -uroot -p -e "use yn_case; show tables;"

init.sql 里除了建表语句,还内置了一条种子数据:一个管理员账号、一个测试部门、一个测试催收员和一个测试案件。种子数据不是随手塞的,它保证第一次登录后不用造数就能把案件领取、电话催收、承诺还款这条主流程走通。如果想要更多测试数据,脚本里还有一段注释掉的数据生成器,取消注释执行一次就能插几千条案件。

4.2 启动后端服务和前端页面

后端和前端都要起,前后端通过接口联调。后端优先用 Maven 直接启动,开发环境不需要装额外的东西。

# 启动 Redis(新开一个终端窗口) redis-server # 启动后端服务,指定 dev 配置 cd yn-server mvn spring-boot:run -Dspring-boot.run.profiles=dev

spring-boot.run.profiles=dev 指定加载 application-dev.yml,里面配的数据库账号密码要改成你自己的。如果 8080 端口被占用,在 yml 里改 server.port。启动日志看到 Started 关键字说明后端起来了。前端在另一个窗口启动:

cd yn-web npm install npm run dev

npm install 装依赖的时间取决于网络,装完以后 npm run dev 会起一个开发服务器,默认 8081 端口。Vue 2 项目最常见的坑是 node-sass 装不上,镜像没配或 Node 版本过新都会报错,把 Node 换到 12 或改用 dart-sass 实现就能解决。浏览器打开 http://localhost:8081,输入种子数据里的账号密码登录。登录后先进案件池页面,把内置的测试案件领取到自己的名下,然后走一遍催收登记,这时候在个人工作台能看到案件状态已经变成催收中。

4.3 验证主流程是否跑通

跑通不是页面能打开就算完。我一般按五步验证,缺一步后面改造容易出隐性 bug:第一,登录后案件池能看到测试案件;第二,领取案件后案件状态变为已分派;第三,在外呼弹窗里选承诺还款,保存后承诺还款列表出现一条记录;第四,管理员账号进入减免审批页,能对测试案件发起减免;第五,还款登记后,案件台账的剩余金额正确减少。这五步覆盖了案件生命周期里最主要的状态流转,任何一步结果不对,说明对应的库表 SQL 或状态枚举没配置好。

排查时先看后端日志,SQL 异常在 MyBatis 的日志里能看到完整语句;前端接口报错在网络面板里看响应体,统一返回体里的 code 字段对应错误码表,错误码 200 表示成功,4xx 是业务规则拦截,5xx 是服务内部异常。

5. 避坑与常见问题排查

5.1 案件重复导入,池子里出现两条同样案件

现象:同步任务跑完后,案件池里同一个合同号出现两条记录,催收员打过去才发现是同一笔债务,浪费时间还影响考核数据。

原因:Excel 导入时逐行 insert,没有在导入前做整体去重,数据库也没有联合唯一索引兜底。定时任务重跑时更容易触发,因为重跑的批次里包含上次已经导入过的单子。

解决:导入接口在解析完成后先按合同号和到期日分组去重,再批量插入;同时给 case_info 加联合唯一索引作为最后一道防线。遇到重复数据不要静默丢弃,要单独记录下来,方便当天核对哪些案件被过滤了。

5.2 两个催收员同时领取案件,一个案件被分了两次

现象:线上反馈有案件同时出现在两个催收员的在办列表里,催收结果登记时互相覆盖。

原因:领取案件的代码是先查状态再 update,两个请求都查到状态为待分派,然后各自 update,后一个把前一个覆盖了,没有乐观条件兜底。

解决:更新 SQL 里加 and status = 0 条件,MyBatis update 返回影响行数为 0 就说明被抢了,直接返回已被领取;再配合 Redis 分布式锁兜底。注意锁的 key 必须包含案件 ID 而不是全局固定 key,否则所有案件共用一把锁,分派吞吐直接拉垮。

5.3 外访定位偏移,人没到现场却签到了

现象:外访人员在楼底下打卡,后台显示的定位跑到隔壁街道去了,质检复核时被判定为虚假外访,催收员有苦说不出。

原因:直接用了 GPS 的原始坐标,没有看 accuracy 精度值。GPS 在楼宇密集区域误差经常超过 50 米,原始坐标不能直接信任。

解决:签到时强制校验 accuracy,大于 50 米提示重新定位。校验通过后再让用户拍照,照片和服务端时间戳绑定。位置数据入库后还要做一次路网反向校验,把在主干道上但实际是隔壁街区的偏离记录标记出来。

5.4 还款后案件余额算不对,台账经常要手工调

现象:还款登记完成后,案件剩余应还金额和还款流水对不上,月底盘点老是要手工改数。

原因:减免审批和还款登记两个操作并发时,都读取台账旧值再计算,后写的覆盖先写的。台账更新没有锁,事务也没隔离住。这类问题开发环境很难复现,因为单用户操作不会出现并发,我用脚本同时发起减免审批和还款登记两个请求才把问题稳定复现。

解决:所有涉及台账余额计算的方法都加悲观锁,更新余额用 SELECT FOR UPDATE 锁定对应行;操作完成后立即更新余额字段,不要在内存里缓存旧值再写回。金额字段一律用 BigDecimal,禁止用浮点型。

5.5 报表查询慢,月底汇总要等十几秒

现象:逾期率、回收率统计报表在案件量超过五十万后明显变慢,页面转圈十几秒才出数据。

原因:报表 SQL 直接在业务表上做全表聚合,关联多张表还没走到索引,每查询一次就把全量数据扫一遍。

解决:按月建汇总表,每天凌晨用 timer 模块跑聚合任务,报表只查汇总表。汇总表冗余了月份、部门、催收员三个维度的统计字段,查询时只做简单过滤,从秒级降到百毫秒级。

6. 二次开发:把分派策略改造成可插拔规则

这套系统改起来最值得动手的地方是案件分派策略。目前 pickCollector 里是 if-else 写死的两种策略,每次加新规则都要改 Service 层。第一步把它抽成策略接口,一个新规则就是一个新实现类,业务流程骨架不用动。

// CollectorStrategy.java 分派策略接口 public interface CollectorStrategy { String strategyCode(); Collector pick(AssignRequest request, List<Collector> candidates); } // 按在途案件量最少的策略 @Service public class LeastLoadStrategy implements CollectorStrategy { @Override public String strategyCode() { return "leastLoad"; } @Override public Collector pick(AssignRequest request, List<Collector> candidates) { return candidates.stream() .min(Comparator.comparingInt(Collector::getInTransitCount)) .orElse(null); } }

改造后 AssignService 里维护一个 Map,Spring 会把所有策略实现类注入进来,按 strategyCode 路由。新增高账龄优先策略时只写一个实现类,不用碰 Service 层。同一个思路可以复制到外访任务分配和语音质检规则上。

还有两个值得扩展的点。一个是外呼对接,把 phoneCall 模块改造成适配层,接入第三方呼叫中心的事件回调,通话状态由回调驱动,而不是前端轮询;另一个是数据权限,目前报表接口是全量可见,可以加一个 MyBatis 拦截器,根据登录人的机构号自动拼接数据过滤条件,防止跨部门看到彼此的案件。

当初接这个系统时,我在案件并发分派和台账余额计算上踩了两次坑,从那以后每次上线前我都强制走一遍并发压测和异常补偿脚本,把两个请求同时操作同一案件、审批与登记并发这两类场景先模拟一遍再放量。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询