☰
催收业务系统核心模块拆解:从案件分配到结清核销的完整闭环
2026/10/19 2:53:16 网站建设 项目流程

简介:这是一份一诺银华催收业务系统的软件详细设计说明书,面向金融催收领域项目开发、系统维护人员及需要了解业务流转与模块划分的中高级开发者。说明书对系统概况、业务需求、功能模块、技术选型与运行环境等进行了明确定义,覆盖案件管理、催收任务分配、客户信息管理、催收结果导出等核心环节,可帮助读者快速建立对催收业务系统的整体认知。压缩包大小约25.27MB,共749个文件,主要包含266个图片资源、126个JavaScript脚本、71个Java源文件、39个XML配置、35个JAR依赖库、25个JSP页面及SQL脚本等,前端页面、后端逻辑与部署配置相对齐全,适合对照设计文档逆向理解工程结构。目前已有3417人学习下载。对于计划开展同类系统设计、二次开发或编写需求文档的读者,这套资料能提供较为完整的模块划分参考、页面流程素材及技术实现思路,节省前期调研与设计梳理时间。

1. 催收业务系统到底能干什么:一个案件从分配到结清的完整闭环

某公司贷后部门每天要处理几千条逾期记录,之前大家靠Excel和在群里吼来分配任务,催收员各自抢单、重复跟进和漏跟时有发生,还经常撞单。这个项目就是一个把“案件分配→催收跟进→还款承诺→结清核销”串起来的贷后催收管理系统,说白了就是催收业务中台。它适合有小额信贷、消费金融、汽车金融这类贷后场景的团队拿去做二次开发,也适合想看看真实业务状态机怎么落地的同学当工程样例。它不只是一堆CRUD,里面案件分配策略、催记敏感词拦截、还款核销幂等等关键模块,都是能直接跑通、能改出花样的地方。

2. 从技术选型到数据模型:这个项目的骨架是怎么立起来的

2.1 技术栈:为什么偏要选Spring Boot+MyBatis Plus

我拿到这份代码的第一反应是“居然没有用重框架”,后端主体是 Java 8 + Spring Boot 2.7,持久层用的 MyBatis-Plus 3.5,配 MySQL 8.0,Redis 做分布式锁和热点缓存,RabbitMQ 做催记后的异步消息。鉴权这一层没有引入 Security 的复杂授权体系,只是 Spring Security + JWT,按接口注解走权限码。前端是 Vue 3 + Vite + Element Plus + Pinia,和后台 Java 服务通过 Axios 调 REST 接口。

为什么这么选,我理解是团队要“能用、好招人、容易交接”。催收业务的核心是流程和状态,不是高并发秒杀,用 Spring Boot 的声明式事务和 MyBatis 的灵活 SQL 就非常顺。MyBatis-Plus 在这里主要是省掉了单表 CRUD 的模板代码,案件表、催记表那些 findByXxx、insertSelective 都不用手写,但真正复杂的联表统计还是写在 XML 里的。常见做法是有人在这里直接上 JPA,但这个项目里状态机字段很多,JPA 对动态字段更新的控制不如 MyBatis 直观,所以我个人也更认可用 MyBatis。

Redis 在这个项目里不是只当缓存用。催记提交防重、案件分配锁、核销回调幂等都要依赖 Redis,所以你在 application.yml 里会看到很多 indigo:lock: 这种 key 前缀。RabbitMQ 则是用来解耦“催记保存成功之后要通知质检、发短信”这种非关键链路,避免病例查询接口被这些动作拖慢。

2.2 目录结构:按业务模块分包,而不是按三层分包

以前很多项目喜欢 controller / service / mapper 平铺,这个项目没这么干。它的后端主包是 com.example.collect,下面先按 assign、case、collection、repay、dashboard 这些业务域分包,每个域内部再放 controller、service、mapper、entity。这样做的好处是,你改“还款核销”时直接进 repay 包,不会翻到一堆无关的 UserMapper。目录结构大致长这样:

collect-server/ ├── src/main/java/com/example/collect │ ├── assign │ │ ├── controller/AssignController.java │ │ ├── service/ │ │ ├── mapper/ │ │ └── entity/ │ ├── case │ │ ├── controller/CaseController.java │ │ ├── service/ │ │ ├── mapper/ │ │ └── entity/ │ ├── collection │ │ ├── controller/CollectionRecordController.java │ │ └── service/ │ ├── repay │ │ ├── controller/RepayPlanController.java │ │ └── service/ │ ├── config │ │ ├── RedisConfig.java │ │ ├── RabbitConfig.java │ │ └── WebMvcConfig.java │ ├── strategy │ │ ├── AssignStrategy.java │ │ └── impl/ │ └── common │ ├── Result.java │ └── exception/ ├── src/main/resources │ ├── db/migration/ │ └── mapper/ └── frontend/ ├── src │ ├── api/ │ ├── views/ │ ├── components/ │ └── store/

看这个结构就知道,项目想让你重点关注的不是通用的增删改查,而是 strategy 这个包。分配策略接口放在顶层,具体实现按金额、轮询、自定义权重分别塞在 impl 里。我一般拿到这种代码会先看 strategy 和 enums,因为业务状态机基本都写在 enums 里,比如案件状态 CaseStatusEnum、核销状态 RepayStatusEnum,这些比接口文档还诚实。

前端则按页面组织:views/case 里是案件列表和详情,views/assign 里是分配策略配置,components 里有催记编辑器、还款计划面板。前端没有做太细的复用,主要是每个业务页面功能独立,共用组件只有导入导出和上传附件这两个。

2.3 核心数据模型:案件表、客户表、催记表、还款计划表的关系

这个项目的表数量不算多,但关系很集中。核心是 case_info 案件主表,它是所有流程的载体;它关联 customer 客户表,在催记表里是记录每次沟通,在 repay_plan 里是维护还款计划。案件表的关键字段我建议你先看这几个:

case_info - id 案件ID - customer_id 客户ID - contract_no 借款合同号 - overdue_amount 当前逾期金额 - overdue_days 逾期天数 - status 0待分配 1催收中 2承诺还款 3已结清 4关闭 - strategy_code 分配策略标识 - version 版本号 - assign_collector_id 当前催收员ID

status 这个字段就是状态机的核心。分配模块只会把 status=0 的案件捞出来,分配完成后置为 1;催收员在案件详情里录催记,如果客户承诺还款,会在 repay_plan 里生成一份计划,然后把案件状态改成 2;核销回调回来了,再把状态改为 3 或回到 1。整个流程没有用 workflow 引擎,就是靠枚举和状态流转逻辑控制,简单直接。

repay_plan 表单独说,它带了一个业务唯一键 plan_no,是核销幂等的关键。每次客户承诺还款后系统生成一个计划单号,渠道方回调时带着这个单号回来,数据库里对 plan_no 做唯一索引,配合乐观锁 version,避免重复核销。这是这个项目我最喜欢的一段设计,后面第 3 章会展开。

2.4 接口设计:一个分配接口把重复作业变成自动化流水线

接口层面因为要给前端和催收员 App 共用,统一返回 Result 结构,里面 code=0 表示成功,非 0 是业务错误码,message 给前端提示,data 承载数据。分页用 MyBatis-Plus 的 Page 对象,前端传 pageNum 和 pageSize 就行。核心接口我列一下:

接口方法作用关键参数
/api/case/assignPOST案件批量分配caseIds、strategyCode
/api/case/{caseId}/recordPOST写入催记followType、content、nextFollowTime
/api/case/{caseId}/repay-planPOST生成还款计划amount、promiseDate
/api/case/batch/callbackPOST渠道还款回调planNo、paidAmount、channel
/api/dashboard/overviewGET首页统计无

一个典型分配请求大概是这样的:

{ "caseIds": [1001, 1002, 1003], "strategyCode": "amount" }

返回体里 data 是一个 List,每条包含 caseId 和 collectorId。前端拿到这个列表后根据 collectorId 去刷新当前队列。这个接口没有做成同步,原因是分配案件数有时会到几千,同步做会卡住请求;它先把分配任务丢进 RabbitMQ,然后返回“任务已接收”,后端 listener 消费完再通过 WebSocket 推送结果。所以如果你看到接口响应很快但案件还没分配,不是 bug,是异步任务还在跑。

3. 核心模块拆解:一个催收案件从分配到结清的完整业务链路

3.1 案件分配模块:策略池和自定义加载器

启动项目后第一件值得研究的事是分配策略。分配接口没有写死在 service 里,而是定义了一个 AssignStrategy 接口,所有策略实现类都注入到一个工厂里。先看接口:

public interface AssignStrategy { /** * 策略标识,对应数据库 case_info.strategy_code */ String strategyCode(); /** * 执行分配,返回 caseId -> collectorId 的映射 * * @param caseIds 待分配的案件ID列表 * @param collectorIds 当前可用的催收员ID列表 */ Map<Long, Long> assign(List<Long> caseIds, List<Long> collectorIds); }

接口本身很薄。真正有意思的是实现类,以金额策略 AmountStrategy 为例:

@Component public class AmountStrategy implements AssignStrategy { @Override public String strategyCode() { return "amount"; } @Override public Map<Long, Long> assign(List<Long> caseIds, List<Long> collectorIds) { Map<Long, Long> result = new HashMap<>(); // 按逾期金额降序排序,顺便只取 status = 0 的案件 List<CaseEntity> cases = caseMapper.selectList(new LambdaQueryWrapper<CaseEntity>() .in(CaseEntity::getId, caseIds) .eq(CaseEntity::getStatus, 0) .orderByDesc(CaseEntity::getOverdueAmount)); for (CaseEntity c : cases) { Long collectorId = pickCollectorByAmount(c.getOverdueAmount(), collectorIds); result.put(c.getId(), collectorId); } return result; } private Long pickCollectorByAmount(Long amount, List<Long> collectorIds) { if (amount < 5000) { return collectorIds.get(0); // 初级催收员 } else if (amount < 20000) { return collectorIds.get(1); // 中级催收员 } else { return collectorIds.get(2); // 资深催收员 } } }

这个实现的逻辑是:先把案件从数据库查出来,确保它们都是待分配状态,然后按逾期金额从大到小排队,再根据金额区间把人分配出去。这里有个细节,collectorIds 的顺序是前端在系统里配置的“催收梯队顺序”,所以代码里直接用索引取人。

实际线上建议不要像这样简单取索引,因为某个人请假时队列会变短,最好加一层“当前在线”过滤。我一般会把它改成查 Redis 的在线状态,而不是让前端传一个写死的列表。接口的 param 说明:caseIds 是业务侧筛选出来的案件,不能为空;strategyCode 为 null 时默认走“round”轮询策略,防止配置错了把接口打崩。

3.2 催记写入模块:敏感词拦截和防重复提交

催记是催收员的日常动作,也是最容易产生脏数据的地方。这个模块有两点设计我认为很有价值:一是前端提交时先落 Redis 锁防重复,二是内容经过敏感词过滤器再入库。看 Controller 和 Service 的配合:

@PostMapping("/{caseId}/record") public Result<Boolean> addRecord(@PathVariable Long caseId, @RequestBody RecordRequest request, @RequestHeader("Authorization") String token) { Long operatorId = JwtUtil.parse(token).getUserId(); boolean ok = collectionService.saveRecord(caseId, operatorId, request); return Result.ok(ok); }

Service 实现:

@Transactional(rollbackFor = Exception.class) public boolean saveRecord(Long caseId, Long operatorId, RecordRequest request) { String key = "collect:record:lock:" + caseId + ":" + operatorId; if (!redisLock.tryLock(key, 15, TimeUnit.SECONDS)) { throw new BizException("5001", "请勿重复提交,正在保存中"); } try { SensitiveCheckResult check = sensitiveWordsFilter.check(request.getContent()); if (check.isHit()) { // 命中敏感词时,记录风控日志,但催记本身仍要保存,只是内容会被脱敏 riskLogService.saveLog(caseId, operatorId, check.getWords()); request.setContent(check.mask()); } RecordEntity entity = RecordEntity.builder() .caseId(caseId) .operatorId(operatorId) .followType(request.getFollowType()) .content(request.getContent()) .nextFollowTime(request.getNextFollowTime()) .build(); recordMapper.insert(entity); // 同步更新案件下一次跟进时间,方便列表按时间排序 caseMapper.updateNextFollowTime(caseId, entity.getNextFollowTime()); return true; } finally { redisLock.unlock(key); } }

这套写法有几个坑要提醒你。第一,Redis 锁必须在 finally 里释放,否则业务抛异常后锁一直刷不掉,后面所有催记都会被拦截。第二,敏感词命中后不要直接让用户“保存失败”,否则催收员会觉得是系统卡了就猛点按钮,反而造成更多重复请求。我习惯的做法是内容强制脱敏并保存,同时把命中的原始词写到风控表里,等质检人员后续处理。

参数说明:followType 有 phone、sms、visit、wechat 四种枚举;nextFollowTime 如果不传,默认是当前时间加 7 天,这是很多团队踩过的坑,不传会导致列表里催记永远排在最后面。

3.3 还款计划与结清核销:幂等回调怎么保证不多扣

还款核销是资金相关操作,最怕重复扣款、重复结清。这个模块的思路是:每次还款计划生成时分配一个全局唯一的 planNo,渠道回调时把这个单号带回来,数据库对 planNo 建立唯一索引,核销时通过乐观锁更新状态。代码是这样:

@Transactional(rollbackFor = Exception.class) public RepayPlanResult handleRepayCallback(RepayCallback callback) { // 1. 先用业务单号查计划,这里 planNo 有唯一索引 RepayPlan plan = repayPlanMapper.selectOne(new LambdaQueryWrapper<RepayPlan>() .eq(RepayPlan::getPlanNo, callback.getPlanNo())); // 2. 已核销的直接返回,不继续往下走 if (plan.getStatus() == 1) { return RepayPlanResult.alreadyDone(plan.getId()); } // 3. 乐观锁更新:status=0 时才能改成已核销 int updated = repayPlanMapper.updateStatusWithVersion( plan.getId(), plan.getVersion(), callback.getPaidAmount()); if (updated == 0) { throw new BizException("5002", "核销冲突,请稍后重试"); } // 4. 更新案件状态,按剩余金额决定是否结清 Long remainAmount = plan.getDueAmount() - plan.getPaidAmount(); if (remainAmount <= 0) { caseMapper.updateStatus(plan.getCaseId(), CaseStatusEnum.SETTLED.getCode()); } else { caseMapper.updateStatus(plan.getCaseId(), CaseStatusEnum.PROMISED.getCode()); } return RepayPlanResult.ok(plan.getId()); }

这段代码的核心在于 updateStatusWithVersion,它的 SQL 通常长这样:

update repay_plan set status = 1, paid_amount = paid_amount + #{paidAmount}, version = version + 1 where id = #{id} and status = 0 and version = #{version}

只有当数据库里 status 还是 0、version 还是旧值时,这个 update 才会真正生效。第一次回调更新成功,第二次回调拿到的还是旧 version,所以 updated 等于 0,直接抛业务异常。这就是幂等。

这里有个容易被忽略的点:paid_amount 用的是“原值 + 本次金额”,不是传入一个总额。如果回调方因为网络重试丢了前一次成功的结果,第二次传的是累计总额,就会导致总金额越加越多。所以我一般会在参数校验里加一条“paidAmount 必须大于 0,且不能等于 plan 的 alreadyPaidAmount + paidAmount 以外的任何值”,防呆要写在消费侧。

3.4 并发控制与数据一致性:两个人同时跟同一案件怎么办

催收系统里最常见的并发场景是:一个案件被分配给 A,但主管在后台强制改派给 B,A 的手机端还保留着房源,于是两个人同时录催记。这个项目用一个简单的 assignedLock 解决:每次打开案件详情时要先拿到当前分配人的锁,写催记时再校验一次case_info.assign_collector_id是否等于当前用户。

public boolean checkAssignee(Long caseId, Long operatorId) { CaseEntity c = caseMapper.selectById(caseId); if (c.getAssignCollectorId() != null && !c.getAssignCollectorId().equals(operatorId)) { // 这里不是单纯返回 false,而是把当前实际处理人带回去 throw new BizException("5003", "该案件已改派给其他催收员,无法继续操作"); } return true; }

这个写法在极端并发下仍可能漏掉,因为“查询-判断-写入”不是原子的。更稳的做法是在写催记的 update 语句里增加assign_collector_id = 当前用户的条件,让数据库帮忙判断。好在催收系统并不会有秒杀级别的写入,加一个 Redis 锁已经能挡掉大部分冲突。如果你们之后要改成多人协访,需要再引入真正的行级锁或者 version 比较。

4. 常见问题与避坑指南:跑通这个项目时我踩过的四个坑

4.1 MySQL时区导致日期查出来偏移8小时

现象:本地启动后,前端案件列表里的“创建时间”全都慢了 8 个小时,催记里的 nextFollowTime 也全不对,很多人以为是 FastJson 格式化问题,检查一圈发现根本不是。

原因:启动命令里的数据库连接串没指定 serverTimezone,MySQL 8.0 默认用了 UTC 时区。Java 应用拿到的时间实际上是正确的时间,但 MyBatis 从 JDBC 读出来时按 UTC 转换,写进本地时区后就差了 8 小时。

解决:在 application.yml 的连接串里加serverTimezone=Asia/Shanghai&useSSL=false&characterEncoding=utf8。另外很多老项目用 MySQL 5.7,没有这个问题,但 8.0 一定要配。我建议直接在数据库初始化命令里也把default-time-zone='+08:00'配上,双保险。

4.2 Flyway脚本大小写不统一导致启动失败

现象:项目用 Flyway 做版本控制,第一次启动好好的,后来加了一张表后启动直接报 “Detected failed migration to version 3”,服务起不来。

原因:有人直接改动了已经执行过的 V1__init.sql,而且表名一会儿用小写一会儿用驼峰。Flyway 在启动时会校验历史脚本的 checksum,一旦发现 V1 的 checksum 和之前记录的不一致,就会中断整个启动流程,防止数据库被改得面目全非。

解决:永远不要修改已经跑过的脚本,需要加表或改字段就新建 V4__xxx.sql。一旦出了这个问题,本地开发环境可以执行mvn flyway:clean再重新跑,线上环境要用flyway repair把校验和错误修掉,但前提是你确定历史库结构没问题。从那以后我每次改数据库结构都会看一眼 migration 目录里最新的版本号,避免在旧文件上改出毛病。

4.3 JWT过期时间设置太短,催收员频繁掉线

现象:催收员反馈用着用着就“登录已过期”,尤其是做外访时在信号不好的地方录一条催记,提交时报 401,这条催记内容全丢了。

原因:项目配置文件里 access-token 过期时间写的是 30 分钟,而前端却没有刷新 token 的逻辑。催收员从打开 App 到录入催记通常超过 30 分钟,于是 token 在请求中途就失效了。

解决:把默认过期时间改成 2 小时,同时在后端做一个白名单刷新接口。不要只调长过期时间,因为手机端长期保持登录状态也有风险。更合理的做法是双 token:access-token 60 分钟,refresh-token 7 天;前端拦截 401 后用 refresh-token 静默续期,续期失败才真正踢下线。

4.4 异步发送短信导致事务回滚后消息照发

现象:保存催记后会调用短信服务给客户发还款提醒,但有时候催记因为敏感词异常回滚了,客户却已经收到了短信,质检会来质问数据不一致。

原因:代码里直接在事务方法内调用了短信服务,而且短信发送是异步线程池执行的。事务还没提交,异步线程已经把短信发出去了,一旦后面事务回滚,催记没了,但短信记录和客户体验都留下痕迹。

解决:用 Spring 的事务事件把操作延后到提交之后。常见做法是TransactionSynchronizationManager.registerSynchronization或者@TransactionalEventListener(phase = AFTER_COMMIT)。我一般会在这个回调里发一条 MQ 消息,再由短信消费者去调用渠道接口,这样就算短信服务失败也不影响主流程。

5. 二次开发:把内置分配规则替换成你自己的策略引擎

5.1 先看现有的策略抽象,别急着推倒重来

原有的 AssignStrategy 接口只有两个方法,意味着你想要一种新分配规则,不需要动 Controller 和 Service,只需要在 strategy/impl 目录下加一个实现类,然后注册成 Spring Bean。注意一个坑:如果你在实现类上标记了@Component,同时又在工厂里用 List 注入所有策略,Spring Boot 会自动把所有实现类装进这个 List,不需要手动 register。看下面的工厂代码:

@Component public class AssignStrategyFactory { private final Map<String, AssignStrategy> strategyMap; public AssignStrategyFactory(List<AssignStrategy> strategyList) { this.strategyMap = strategyList.stream() .collect(Collectors.toMap(AssignStrategy::strategyCode, s -> s)); } public AssignStrategy getStrategy(String code) { return strategyMap.getOrDefault(code, strategyMap.get("round")); } }

这段代码的精髓是先同时注入 List 再转 Map,这样以后新增策略不会破坏已有的 key。注意toMap如果遇到两个实现类返回相同 strategyCode 会抛异常,所以每个策略的 code 必须唯一。

5.2 实现一个按“客户标签权重”的分配策略

假设你们公司想优先把“本地户籍、历史还款良好”的案件分给资深催收员,把“高风险、有投诉记录”的案件分给合规专员,内置的 amount 和 round 都不满足,就可以自己写一个 WeightStrategy:

@Component public class WeightStrategy implements AssignStrategy { @Override public String strategyCode() { return "weight"; } @Override public Map<Long, Long> assign(List<Long> caseIds, List<Long> collectorIds) { Map<Long, Long> result = new HashMap<>(); List<CaseEntity> cases = caseMapper.selectList( new LambdaQueryWrapper<CaseEntity>() .in(CaseEntity::getId, caseIds) .eq(CaseEntity::getStatus, 0)); for (CaseEntity c : cases) { int score = 0; // 本地户籍权重:优先高分数 if ("本地".equals(c.getAddressType())) { score += 10; } // 还款历史权重:历史正常表示风险低 if (Integer.valueOf(3).equals(c.getComplaintLevel())) { score -= 20; } // 按分数段匹配催收员队列 Long collectorId = pickByScore(score, collectorIds); result.put(c.getId(), collectorId); } return result; } private Long pickByScore(int score, List<Long> collectorIds) { if (score >= 10) { return collectorIds.get(0); } else if (score >= 0) { return collectorIds.get(1); } else { return collectorIds.get(2); } } }

改造时不要把 score 计算逻辑直接写死在实现类里,我一般会拆一个ScoreCalculator,方便以后引入规则引擎。如果要支持可视化配置,就把评分规则存到数据库,策略实现只是一个执行器,这样运营调整权重不用发版。

5.3 验证方法:用 MockMvc 跑批量分配测试

改完策略以后一定要验证两个点:是否所有案件都被分配出去了,是否同一个案件被重复分配。下面的测试用 JUnit 5 加 MockMvc 造了 100 个案件,然后断言分配结果不重不漏:

@SpringBootTest @AutoConfigureMockMvc public class AssignStrategyTest { @Autowired private MockMvc mockMvc; @Test void testWeightStrategyAssignAllCases() throws Exception { List<Long> caseIds = new ArrayList<>(); for (long i = 2000; i < 2100; i++) { caseIds.add(i); } mockMvc.perform(post("/api/case/assign") .header("Authorization", "Bearer test-token") .contentType(MediaType.APPLICATION_JSON) .content("{\"caseIds\":[2000,...]," + "\"strategyCode\":\"weight\"}")) .andExpect(status().isOk()); // 这里用真正的 service 层检查数据库分配结果 List<CaseEntity> stillWaiting = caseMapper.selectList( new LambdaQueryWrapper<CaseEntity>() .in(CaseEntity::getId, caseIds) .eq(CaseEntity::getStatus, 0)); Assertions.assertTrue(stillWaiting.isEmpty()); } }

测试里最重要的不是 HTTP 状态码,而是最后那条stillWaiting.isEmpty()。分配策略如果漏了条件,案件会一直停留在待分配状态,列表页看起来就像“消失了”,这是最隐蔽的问题。内存测试不好覆盖到,需要真的跑一遍数据库查询才靠谱。我后来每次加新策略都会把这个测试脚本留着,跑回归时几秒钟就能知道有没有把旧策略破坏。

从那以后我每次接手带状态机的项目,都强制先画一遍状态流转图再改代码,催收、核销、分配这三个模块尤其如此。希望帮到你。

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

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

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

立即咨询