案例 1:信贷风控准入决策
本案例对应 JQuick-Java 仓库 USE-CASES.md 中的场景 1:信贷风控准入决策(生产级样板),属于金融风控领域,解决个人消费信贷进件的自动审批决策:对一笔进件执行前置硬校验、五维评分卡汇总、风险等级映射,并输出准入结论、建议授信额度与年化执行利率。
个人消费信贷进件审批是银行、消费金融公司信贷工厂的核心入口环节。一笔进件的要素涵盖单位性质、现单位在职月数、社保连续缴纳月数、月均收入与月均负债还款额、近 2 年征信逾期次数与最长逾期天数、近 3 个月征信查询次数、抵(质)押物类型与评估价值、是否存在涉诉/被执行记录以及所在城市层级等,来源于真实进件表单与征信报告摘要。
审批决策采用「多维评分卡 + 决策映射」结构:负债收入比(025 分)、历史逾期(030 分)、职业稳定性(015 分)、征信查询(-510 分)、抵押物(020 分)五个维度独立打分,由编排层汇总为 0100 的风控总分,再映射出 A/B/C/D 风险等级、PASS/MANUAL_REVIEW/REJECT 准入结论、按月收入乘风险系数测算的建议授信额度,以及与风险等级挂钩的年化执行利率。
最终决策输出物不仅是一个结论,还包括各维度评分明细、拒绝原因清单与风险提示清单:高风险客户要给出「负债收入比过高」「历史逾期严重,最长逾期天数 N 天」等具体拒贷理由;通过的客户也要提示多头借贷、社保缴纳不足、无抵押纯信用授信、区域集中度等风险信号,供信审人员人工复核与模型回溯使用。
此外,年龄(22~60 周岁)、收入有效性、申请金额有效性属于不进入评分模型的前置硬规则,命中即直接拒绝;涉诉/被执行记录则是评分之外的一票否决项——即使模型总分达到 92 分的 A 级,也必须拒绝。而高分客户若近 3 月征信查询过多(多头借贷信号),即使模型给出 PASS,也会被自动降级为人工复核。
- 评分阈值口径散落硬编码:负债率 0.2/0.4/0.5/0.6 分档线、逾期次数与最长逾期天数的交叉矩阵、85/70/55 的等级与准入线,往往散落在网点、网贷、信用卡等多个进件渠道的
if-else中,口径难以统一。 - 政策调整必须重新发版:授信额度倍数(30/24/18/12 倍月收入)、各等级执行利率(3.85%/4.95%/6.80%)需要随监管要求与市场窗口频繁调整,硬编码方式下每次调参都要走完整编译发版流程。
- 多维评分耦合,难独立调参与回溯:五个维度写在一个大方法里时,单维度(如征信查询口径)调整需要回归整条链路,且一笔被拒贷款说不清各维度分别得了多少分,模型迭代缺少依据。
- 决策结果不可解释、难审计:只有「通过/拒绝」两个结论,缺少拒绝原因、风险提示和评分明细,信审复核、客诉处理与监管检查时无法还原决策依据。
- 横断逻辑容易漏判错判:年龄硬拒绝、涉诉一票否决、高分多头查询降级人工等逻辑与评分流程混写,边界形态(如 92 分但涉诉、85 分但查询 10 次)最容易出错。
- 老旧核心与信创环境难引入重型平台:大量信贷核心系统仍运行在 JDK 8 或国产化配套 JDK 上,Drools 一类重型规则平台升级成本高、运维负担重,难以嵌入高频进件链路。
本案例采用「领域模型 + 规则接口与 XML 规则脚本 + 编排服务 + 测试」的六件套结构:规则口径全部外置在 XML 中,Java 侧只保留前置校验、分数汇总封顶、额度封顶与决策组装。
| 层次 | 职责 | 对应文件 |
|---|---|---|
| 领域模型 | 进件要素、授信审批决策结果 | CreditApplication.java、AdmissionDecision.java |
| 规则集与 XML | 5 个评分维度 + 5 个决策映射,口径全部外置 | RiskAdmissionRules.java、risk-admission.xml |
| 编排服务 | 前置校验、维度汇总、额度封顶、拒绝原因与风险提示组装 | RiskAdmissionService.java |
| 测试 | 6 组真实业务形态 | RiskAdmissionServiceTest.java |
规则方法拆分
Section titled “规则方法拆分”规则接口共 10 个方法,评分类各管一个维度,决策类只管映射,每个方法只做一件事,便于单维度调参与审计回溯:
| 规则方法 | 口径 | 分值/输出 |
|---|---|---|
scoreDebtRatio |
月负债还款额 / 月收入,按 0.2、0.4、0.5、0.6 分档 | 0 ~ 25 |
scoreOverdue |
逾期次数 × 最长逾期天数交叉分档 | 0 ~ 30 |
scoreEmployment |
在职月数分档 + 单位性质(国企/上市/外企/民营)叠加 | 0 ~ 15 |
scoreCreditQuery |
近 3 月征信查询次数,多头借贷信号扣分 | -5 ~ 10 |
scoreCollateral |
抵押物类型(房产/车辆)+ 价值覆盖倍数 | 0 ~ 20 |
riskLevel |
总分映射 A / B / C / D(85/70/55 三条线) | - |
admit |
总分 + 涉诉一票否决,映射 PASS / MANUAL_REVIEW / REJECT | - |
suggestLimit |
月收入 × 风险系数(30/24/18/12/0 倍) | 建议额度(元) |
interestRate |
风险等级映射年化执行利率 | 0.0385 / 0.0495 / 0.0680 / 0 |
needManualReview |
总分处于 55~70 中等区间或查询次数大于 8,转人工复核 | boolean |
代码实现(完整可运行)
Section titled “代码实现(完整可运行)”将六个文件按源码原有包结构放入 Maven 工程的 src/test 对应目录:模型类 CreditApplication.java、AdmissionDecision.java 位于 com.github.paohaijiao.demo.model;规则接口 RiskAdmissionRules.java 与测试类 RiskAdmissionServiceTest.java 位于 com.github.paohaijiao.demo;编排服务 RiskAdmissionService.java 位于 com.github.paohaijiao.demo.service;规则脚本 risk-admission.xml 位于 src/test/resources/demo/。六个文件的包名保持源码原样,模型类依赖 Lombok 的 @Data 注解。在工程根目录执行 mvn test -Dtest=RiskAdmissionServiceTest 即可运行全部 6 个用例。
CreditApplication.java
Section titled “CreditApplication.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 信贷授信申请件(进件要素) * <p> * 字段来源于真实个人消费信贷进件表单与征信报告摘要,仅保留风控评分所需的关键要素。 */@Data@NoArgsConstructor@AllArgsConstructorpublic class CreditApplication {
/** * 申请编号 */ private String applicationId;
/** * 申请人姓名 */ private String applicantName;
/** * 年龄 */ private int age;
/** * 单位性质:SOE-国企 / LISTED-上市公司 / FOREIGN-外企 / PRIVATE-民营 / SELF_EMPLOYED-自雇 */ private String companyType;
/** * 现单位在职月数 */ private int employedMonths;
/** * 社保连续缴纳月数 */ private int socialSecurityMonths;
/** * 月均收入(元) */ private double monthlyIncome;
/** * 月均负债还款额(元),含已有贷款与信用卡最低还款 */ private double monthlyDebt;
/** * 近 2 年逾期次数 */ private int overdueCount;
/** * 近 2 年最长逾期天数 */ private int maxOverdueDays;
/** * 近 3 个月征信查询次数 */ private int creditQueryCount3M;
/** * 申请金额(元) */ private double loanAmount;
/** * 申请期限(月) */ private int loanTermMonths;
/** * 抵押物类型:HOUSE-房产 / VEHICLE-车辆 / NONE-无抵押 */ private String collateralType;
/** * 抵押物评估价值(元) */ private double collateralValue;
/** * 是否存在涉诉/被执行记录 */ private boolean hasJudicialRecord;
/** * 所在城市层级:TIER_1-一线 / TIER_2-二线 / TIER_3-三线及以下 */ private String cityTier;}AdmissionDecision.java
Section titled “AdmissionDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 授信审批决策结果 */@Datapublic class AdmissionDecision {
/** * 申请编号 */ private String applicationId;
/** * 准入结论:PASS-通过 / MANUAL_REVIEW-人工复核 / REJECT-拒绝 */ private String admit;
/** * 风控总分(0-100) */ private int totalScore;
/** * 风险等级:A / B / C / D */ private String riskLevel;
/** * 建议授信额度(元) */ private double suggestedLimit;
/** * 执行利率(年化) */ private double interestRate;
/** * 各维度评分明细,便于人工复核与模型回溯 */ private List<String> scoreDetails = new ArrayList<String>();
/** * 拒绝原因 */ private List<String> rejectReasons = new ArrayList<String>();
/** * 风险提示 */ private List<String> riskWarnings = new ArrayList<String>();
public void addRejectReason(String reason) { this.rejectReasons.add(reason); }
public void addRiskWarning(String warning) { this.riskWarnings.add(warning); }
public void addScoreDetail(String detail) { this.scoreDetails.add(detail); }}RiskAdmissionRules.java
Section titled “RiskAdmissionRules.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/** * 场景 1(生产级样板):信贷风控准入规则集 * <p> * 规则按「多维评分卡 + 决策映射」拆分,每个方法只做一件事: * <ul> * <li>评分类:输出单维度得分,由 Service 汇总,便于模型回溯与单维度调参</li> * <li>决策类:输入汇总分,输出准入结论、风险等级、额度、利率</li> * </ul> * 规则脚本见 {@code src/test/resources/demo/risk-admission.xml}。 */public interface RiskAdmissionRules {
/** * 负债收入比评分(0-25):月负债还款额 / 月收入 */ int scoreDebtRatio(@Param("monthlyIncome") double monthlyIncome, @Param("monthlyDebt") double monthlyDebt);
/** * 历史逾期评分(0-30):结合逾期次数与最长逾期天数 */ int scoreOverdue(@Param("overdueCount") int overdueCount, @Param("maxOverdueDays") int maxOverdueDays);
/** * 职业稳定性评分(0-15):在职月数 + 单位性质 */ int scoreEmployment(@Param("employedMonths") int employedMonths, @Param("companyType") String companyType);
/** * 征信查询评分(-5 ~ 10):近 3 个月查询次数越多得分越低 */ int scoreCreditQuery(@Param("queryCount3M") int queryCount3M);
/** * 抵押物评分(0-20):抵押物类型与覆盖倍数 */ int scoreCollateral(@Param("collateralType") String collateralType, @Param("collateralValue") double collateralValue, @Param("loanAmount") double loanAmount);
/** * 风险等级映射:A / B / C / D */ String riskLevel(@Param("totalScore") int totalScore);
/** * 准入结论:PASS / MANUAL_REVIEW / REJECT */ String admit(@Param("totalScore") int totalScore, @Param("hasJudicialRecord") boolean hasJudicialRecord);
/** * 依据月收入与总分测算建议授信额度 */ double suggestLimit(@Param("monthlyIncome") double monthlyIncome, @Param("totalScore") int totalScore);
/** * 依据风险等级给出年化执行利率 */ double interestRate(@Param("riskLevel") String riskLevel);
/** * 是否触发人工复核(中等分区间或多头借贷查询) */ boolean needManualReview(@Param("totalScore") int totalScore, @Param("queryCount3M") int queryCount3M);}risk-admission.xml
Section titled “risk-admission.xml”<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE javas PUBLIC "-//PAOHAIJIAO//DTD API JAVA 1.0//EN" "classpath:paohaijiao/dtd/Jquick-java.dtd"><javas namespace="com.github.paohaijiao.demo.RiskAdmissionRules">
<!-- 负债收入比评分:0-25 分 --> <java name="scoreDebtRatio" returnClass="int"> <![CDATA[ int def scoreDebtRatio(double:monthlyIncome, double:monthlyDebt) { if (monthlyIncome <= 0.0) { return 0; } double ratio = monthlyDebt / monthlyIncome; if (ratio <= 0.2) { return 25; } else if (ratio <= 0.4) { return 20; } else if (ratio <= 0.5) { return 15; } else if (ratio <= 0.6) { return 8; } else { return 0; } } ]]> </java>
<!-- 历史逾期评分:0-30 分,逾期越久越差 --> <java name="scoreOverdue" returnClass="int"> <![CDATA[ int def scoreOverdue(int:overdueCount, int:maxOverdueDays) { if (overdueCount == 0) { return 30; } else if (overdueCount <= 2 && maxOverdueDays <= 30) { return 20; } else if (overdueCount <= 4 && maxOverdueDays <= 60) { return 10; } else if (maxOverdueDays <= 90) { return 5; } else { return 0; } } ]]> </java>
<!-- 职业稳定性评分:0-15 分,在职月数 + 单位性质 --> <java name="scoreEmployment" returnClass="int"> <![CDATA[ int def scoreEmployment(int:employedMonths, str:companyType) { int score = 0; if (employedMonths >= 36) { score = 10; } else if (employedMonths >= 12) { score = 7; } else if (employedMonths >= 6) { score = 4; } if (companyType == "SOE" || companyType == "LISTED") { score = score + 5; } else if (companyType == "FOREIGN") { score = score + 4; } else if (companyType == "PRIVATE") { score = score + 2; } return score; } ]]> </java>
<!-- 征信查询评分:-5 ~ 10 分,多头借贷信号 --> <java name="scoreCreditQuery" returnClass="int"> <![CDATA[ int def scoreCreditQuery(int:queryCount3M) { if (queryCount3M <= 2) { return 10; } else if (queryCount3M <= 5) { return 5; } else if (queryCount3M <= 8) { return 0; } else { return -5; } } ]]> </java>
<!-- 抵押物评分:0-20 分,结合覆盖率 --> <java name="scoreCollateral" returnClass="int"> <![CDATA[ int def scoreCollateral(str:collateralType, double:collateralValue, double:loanAmount) { if (loanAmount <= 0.0) { return 0; } if (collateralType == "HOUSE") { double cover = collateralValue / loanAmount; if (cover >= 1.5) { return 20; } else if (cover >= 1.0) { return 15; } else { return 8; } } else if (collateralType == "VEHICLE") { double cover = collateralValue / loanAmount; if (cover >= 1.5) { return 12; } else if (cover >= 1.0) { return 8; } else { return 4; } } else { return 0; } } ]]> </java>
<!-- 风险等级映射 --> <java name="riskLevel" returnClass="java.lang.String"> <![CDATA[ str def riskLevel(int:totalScore) { if (totalScore >= 85) { return "A"; } else if (totalScore >= 70) { return "B"; } else if (totalScore >= 55) { return "C"; } else { return "D"; } } ]]> </java>
<!-- 准入结论:涉诉一票否决 --> <java name="admit" returnClass="java.lang.String"> <![CDATA[ str def admit(int:totalScore, boolean:hasJudicialRecord) { if (hasJudicialRecord) { return "REJECT"; } else if (totalScore >= 70) { return "PASS"; } else if (totalScore >= 55) { return "MANUAL_REVIEW"; } else { return "REJECT"; } } ]]> </java>
<!-- 建议额度:月收入 × 风险系数 --> <java name="suggestLimit" returnClass="double"> <![CDATA[ double def suggestLimit(double:monthlyIncome, int:totalScore) { double multiplier = 0.0; if (totalScore >= 90) { multiplier = 30.0; } else if (totalScore >= 80) { multiplier = 24.0; } else if (totalScore >= 70) { multiplier = 18.0; } else if (totalScore >= 55) { multiplier = 12.0; } else { multiplier = 0.0; } return monthlyIncome * multiplier; } ]]> </java>
<!-- 风险定价:等级越低利率越高 --> <java name="interestRate" returnClass="double"> <![CDATA[ double def interestRate(str:riskLevel) { if (riskLevel == "A") { return 0.0385; } else if (riskLevel == "B") { return 0.0495; } else if (riskLevel == "C") { return 0.0680; } else { return 0.0; } } ]]> </java>
<!-- 人工复核触发条件 --> <java name="needManualReview" returnClass="boolean"> <![CDATA[ boolean def needManualReview(int:totalScore, int:queryCount3M) { if (totalScore >= 55 && totalScore < 70) { return true; } else if (queryCount3M > 8) { return true; } else { return false; } } ]]> </java>
</javas>RiskAdmissionService.java
Section titled “RiskAdmissionService.java”package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.RiskAdmissionRules;import com.github.paohaijiao.demo.model.AdmissionDecision;import com.github.paohaijiao.demo.model.CreditApplication;
import java.math.BigDecimal;import java.math.RoundingMode;
/** * 场景 1:信贷风控准入服务 * <p> * 职责划分: * <ul> * <li><b>Java 侧</b>:进件校验、维度汇总、额度封顶、决策组装、拒绝原因与风险提示</li> * <li><b>规则侧</b>:评分口径、阈值、等级映射、定价,全部外置在 XML 中,可热更新</li> * </ul> * 规则方法只接受标量参数(int / double / boolean / String),复杂对象由本层拆解后传入。 */public class RiskAdmissionService {
/** * 单笔授信额度上限(元) */ private static final double LIMIT_CAP = 500000.0;
/** * 准入政策年龄下限 */ private static final int MIN_AGE = 22;
/** * 准入政策年龄上限 */ private static final int MAX_AGE = 60;
private final RiskAdmissionRules rules;
public RiskAdmissionService(RiskAdmissionRules rules) { this.rules = rules; }
/** * 执行授信审批 * * @param application 进件要素 * @return 审批决策结果 */ public AdmissionDecision evaluate(CreditApplication application) { AdmissionDecision decision = new AdmissionDecision(); decision.setApplicationId(application.getApplicationId());
if (!passPreCheck(application, decision)) { return decision; }
int debtScore = rules.scoreDebtRatio(application.getMonthlyIncome(), application.getMonthlyDebt()); int overdueScore = rules.scoreOverdue(application.getOverdueCount(), application.getMaxOverdueDays()); int employmentScore = rules.scoreEmployment(application.getEmployedMonths(), application.getCompanyType()); int queryScore = rules.scoreCreditQuery(application.getCreditQueryCount3M()); int collateralScore = rules.scoreCollateral(application.getCollateralType(), application.getCollateralValue(), application.getLoanAmount());
int totalScore = debtScore + overdueScore + employmentScore + queryScore + collateralScore; if (totalScore < 0) { totalScore = 0; } else if (totalScore > 100) { totalScore = 100; }
decision.addScoreDetail("负债收入比评分: " + debtScore + " / 25"); decision.addScoreDetail("历史逾期评分: " + overdueScore + " / 30"); decision.addScoreDetail("职业稳定性评分: " + employmentScore + " / 15"); decision.addScoreDetail("征信查询评分: " + queryScore + " / 10"); decision.addScoreDetail("抵押物评分: " + collateralScore + " / 20"); decision.addScoreDetail("汇总评分: " + totalScore);
String riskLevel = rules.riskLevel(totalScore); String admit = rules.admit(totalScore, application.isHasJudicialRecord());
if ("PASS".equals(admit) && rules.needManualReview(totalScore, application.getCreditQueryCount3M())) { admit = "MANUAL_REVIEW"; decision.addRiskWarning("征信查询次数超出自动审批阈值,转人工复核"); }
decision.setTotalScore(totalScore); decision.setRiskLevel(riskLevel); decision.setAdmit(admit);
if ("REJECT".equals(admit)) { buildRejectReasons(application, decision, totalScore, debtScore, overdueScore); return decision; }
double limit = rules.suggestLimit(application.getMonthlyIncome(), totalScore); decision.setSuggestedLimit(capLimit(limit, application.getLoanAmount())); decision.setInterestRate(rules.interestRate(riskLevel)); buildRiskWarnings(application, decision); return decision; }
/** * 前置硬规则校验:不满足则直接拒绝,不进入评分模型 */ private boolean passPreCheck(CreditApplication application, AdmissionDecision decision) { if (application.getAge() < MIN_AGE || application.getAge() > MAX_AGE) { reject(decision, "年龄不符合准入政策(" + MIN_AGE + "-" + MAX_AGE + " 周岁)"); return false; } if (application.getMonthlyIncome() <= 0.0) { reject(decision, "收入信息缺失或无效,无法完成授信评估"); return false; } if (application.getLoanAmount() <= 0.0) { reject(decision, "申请金额无效"); return false; } return true; }
private void reject(AdmissionDecision decision, String reason) { decision.setAdmit("REJECT"); decision.setRiskLevel("D"); decision.setTotalScore(0); decision.setSuggestedLimit(0.0); decision.setInterestRate(0.0); decision.addRejectReason(reason); }
private void buildRejectReasons(CreditApplication application, AdmissionDecision decision, int totalScore, int debtScore, int overdueScore) { if (application.isHasJudicialRecord()) { decision.addRejectReason("存在涉诉/被执行记录,触发一票否决"); } if (debtScore == 0) { decision.addRejectReason("负债收入比过高,超出可承受范围"); } if (overdueScore <= 5) { decision.addRejectReason("历史逾期严重,最长逾期天数 " + application.getMaxOverdueDays() + " 天"); } if (decision.getRejectReasons().isEmpty()) { decision.addRejectReason("综合评分不足,总分 " + totalScore + " 低于准入线 55"); } }
private void buildRiskWarnings(CreditApplication application, AdmissionDecision decision) { BigDecimal income = BigDecimal.valueOf(application.getMonthlyIncome()); BigDecimal debt = BigDecimal.valueOf(application.getMonthlyDebt()); double debtRatio = debt.divide(income, 4, RoundingMode.HALF_UP).doubleValue(); if (debtRatio > 0.5) { decision.addRiskWarning("负债收入比 " + String.format("%.2f", debtRatio) + " 超过 50% 预警线"); } if (application.getCreditQueryCount3M() > 5) { decision.addRiskWarning("近 3 个月征信查询 " + application.getCreditQueryCount3M() + " 次,存在多头借贷迹象"); } if (application.getSocialSecurityMonths() < 6) { decision.addRiskWarning("社保连续缴纳不足 6 个月,收入稳定性待核实"); } if ("NONE".equals(application.getCollateralType())) { decision.addRiskWarning("无抵押物,按纯信用方式授信"); } if ("TIER_3".equals(application.getCityTier())) { decision.addRiskWarning("三线及以下城市进件,区域集中度需关注"); } }
private double capLimit(double limit, double loanAmount) { double result = limit; if (result > LIMIT_CAP) { result = LIMIT_CAP; } if (result > loanAmount) { result = loanAmount; } return BigDecimal.valueOf(result).setScale(2, RoundingMode.HALF_UP).doubleValue(); }}RiskAdmissionServiceTest.java
Section titled “RiskAdmissionServiceTest.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.AdmissionDecision;import com.github.paohaijiao.demo.model.CreditApplication;import com.github.paohaijiao.demo.service.RiskAdmissionService;import com.github.paohaijiao.xml.JQuickJava;import org.junit.Assert;import org.junit.Before;import org.junit.Test;
/** * 场景 1 生产级样板:信贷风控准入 * <p> * 覆盖 6 组真实业务形态:优质客户、高风险多头借贷、边界客户、高分多头查询、 * 涉诉一票否决、年龄不符硬拒绝。 */public class RiskAdmissionServiceTest {
private RiskAdmissionService service;
@Before public void setUp() { RiskAdmissionRules rules = JQuickJava.create() .createApi(RiskAdmissionRules.class, "demo/risk-admission.xml"); this.service = new RiskAdmissionService(rules); }
/** * 优质客户:国企、收入稳定、无逾期、房产足值抵押 → 自动通过、A 级、低利率 */ @Test public void shouldPassPrimeCustomerWithTopScore() { CreditApplication application = new CreditApplication(); application.setApplicationId("APP20260826001"); application.setApplicantName("张伟"); application.setAge(35); application.setCompanyType("SOE"); application.setEmployedMonths(84); application.setSocialSecurityMonths(60); application.setMonthlyIncome(30000.0); application.setMonthlyDebt(4500.0); application.setOverdueCount(0); application.setMaxOverdueDays(0); application.setCreditQueryCount3M(2); application.setLoanAmount(300000.0); application.setLoanTermMonths(36); application.setCollateralType("HOUSE"); application.setCollateralValue(600000.0); application.setHasJudicialRecord(false); application.setCityTier("TIER_1");
AdmissionDecision decision = service.evaluate(application);
Assert.assertEquals("PASS", decision.getAdmit()); Assert.assertEquals(100, decision.getTotalScore()); Assert.assertEquals("A", decision.getRiskLevel()); Assert.assertEquals(300000.0, decision.getSuggestedLimit(), 0.001); Assert.assertEquals(0.0385, decision.getInterestRate(), 0.0001); Assert.assertTrue(decision.getRejectReasons().isEmpty()); Assert.assertTrue(decision.getRiskWarnings().isEmpty()); Assert.assertEquals(6, decision.getScoreDetails().size()); }
/** * 高风险客户:自雇、无社保、负债率 75%、6 次逾期且最长 120 天、近 3 月查询 12 次 → 拒绝 */ @Test public void shouldRejectHighRiskCustomer() { CreditApplication application = new CreditApplication(); application.setApplicationId("APP20260826002"); application.setApplicantName("李强"); application.setAge(31); application.setCompanyType("SELF_EMPLOYED"); application.setEmployedMonths(8); application.setSocialSecurityMonths(0); application.setMonthlyIncome(12000.0); application.setMonthlyDebt(9000.0); application.setOverdueCount(6); application.setMaxOverdueDays(120); application.setCreditQueryCount3M(12); application.setLoanAmount(200000.0); application.setLoanTermMonths(24); application.setCollateralType("NONE"); application.setCollateralValue(0.0); application.setHasJudicialRecord(false); application.setCityTier("TIER_3");
AdmissionDecision decision = service.evaluate(application);
Assert.assertEquals("REJECT", decision.getAdmit()); Assert.assertEquals(0, decision.getTotalScore()); Assert.assertEquals("D", decision.getRiskLevel()); Assert.assertEquals(0.0, decision.getSuggestedLimit(), 0.001); Assert.assertEquals(0.0, decision.getInterestRate(), 0.0001); Assert.assertEquals(2, decision.getRejectReasons().size()); Assert.assertTrue(decision.getRejectReasons().contains("负债收入比过高,超出可承受范围")); Assert.assertTrue(decision.getRejectReasons().contains("历史逾期严重,最长逾期天数 120 天")); }
/** * 边界客户:民营在职 20 个月、负债率 46%、1 次短期逾期、车抵 → 人工复核、C 级 */ @Test public void shouldRouteBorderlineCustomerToManualReview() { CreditApplication application = new CreditApplication(); application.setApplicationId("APP20260826003"); application.setApplicantName("王芳"); application.setAge(28); application.setCompanyType("PRIVATE"); application.setEmployedMonths(20); application.setSocialSecurityMonths(12); application.setMonthlyIncome(15000.0); application.setMonthlyDebt(6900.0); application.setOverdueCount(1); application.setMaxOverdueDays(20); application.setCreditQueryCount3M(4); application.setLoanAmount(100000.0); application.setLoanTermMonths(24); application.setCollateralType("VEHICLE"); application.setCollateralValue(130000.0); application.setHasJudicialRecord(false); application.setCityTier("TIER_2");
AdmissionDecision decision = service.evaluate(application);
Assert.assertEquals("MANUAL_REVIEW", decision.getAdmit()); Assert.assertEquals(57, decision.getTotalScore()); Assert.assertEquals("C", decision.getRiskLevel()); Assert.assertEquals(100000.0, decision.getSuggestedLimit(), 0.001); Assert.assertEquals(0.0680, decision.getInterestRate(), 0.0001); }
/** * 高分但多头查询:模型 85 分、A 级,但近 3 月查询 10 次 → 自动降级为人工复核 */ @Test public void shouldEscalateHighScoreCustomerWithFrequentQueries() { CreditApplication application = new CreditApplication(); application.setApplicationId("APP20260826004"); application.setApplicantName("赵敏"); application.setAge(40); application.setCompanyType("LISTED"); application.setEmployedMonths(60); application.setSocialSecurityMonths(48); application.setMonthlyIncome(25000.0); application.setMonthlyDebt(5000.0); application.setOverdueCount(0); application.setMaxOverdueDays(0); application.setCreditQueryCount3M(10); application.setLoanAmount(200000.0); application.setLoanTermMonths(36); application.setCollateralType("HOUSE"); application.setCollateralValue(400000.0); application.setHasJudicialRecord(false); application.setCityTier("TIER_1");
AdmissionDecision decision = service.evaluate(application);
Assert.assertEquals("MANUAL_REVIEW", decision.getAdmit()); Assert.assertEquals(85, decision.getTotalScore()); Assert.assertEquals("A", decision.getRiskLevel()); Assert.assertEquals(200000.0, decision.getSuggestedLimit(), 0.001); Assert.assertTrue(decision.getRiskWarnings().contains("征信查询次数超出自动审批阈值,转人工复核")); Assert.assertTrue(decision.getRiskWarnings().contains("近 3 个月征信查询 10 次,存在多头借贷迹象")); }
/** * 涉诉客户:模型评分 92 分(A 级),但存在被执行记录 → 一票否决 */ @Test public void shouldRejectCustomerWithJudicialRecord() { CreditApplication application = new CreditApplication(); application.setApplicationId("APP20260826005"); application.setApplicantName("陈刚"); application.setAge(38); application.setCompanyType("PRIVATE"); application.setEmployedMonths(48); application.setSocialSecurityMonths(36); application.setMonthlyIncome(20000.0); application.setMonthlyDebt(3000.0); application.setOverdueCount(0); application.setMaxOverdueDays(0); application.setCreditQueryCount3M(3); application.setLoanAmount(150000.0); application.setLoanTermMonths(36); application.setCollateralType("HOUSE"); application.setCollateralValue(300000.0); application.setHasJudicialRecord(true); application.setCityTier("TIER_2");
AdmissionDecision decision = service.evaluate(application);
Assert.assertEquals("REJECT", decision.getAdmit()); Assert.assertEquals(92, decision.getTotalScore()); Assert.assertEquals("A", decision.getRiskLevel()); Assert.assertEquals(0.0, decision.getSuggestedLimit(), 0.001); Assert.assertEquals(0.0, decision.getInterestRate(), 0.0001); Assert.assertTrue(decision.getRejectReasons().contains("存在涉诉/被执行记录,触发一票否决")); }
/** * 年龄不符:19 周岁 → 前置硬规则拒绝,不进入评分模型 */ @Test public void shouldRejectUnderageApplicantBeforeScoring() { CreditApplication application = new CreditApplication(); application.setApplicationId("APP20260826006"); application.setApplicantName("孙悦"); application.setAge(19); application.setCompanyType("PRIVATE"); application.setEmployedMonths(6); application.setSocialSecurityMonths(3); application.setMonthlyIncome(8000.0); application.setMonthlyDebt(1000.0); application.setOverdueCount(0); application.setMaxOverdueDays(0); application.setCreditQueryCount3M(1); application.setLoanAmount(50000.0); application.setLoanTermMonths(12); application.setCollateralType("NONE"); application.setCollateralValue(0.0); application.setHasJudicialRecord(false); application.setCityTier("TIER_2");
AdmissionDecision decision = service.evaluate(application);
Assert.assertEquals("REJECT", decision.getAdmit()); Assert.assertEquals(0, decision.getTotalScore()); Assert.assertEquals(0.0, decision.getSuggestedLimit(), 0.001); Assert.assertTrue(decision.getRejectReasons().contains("年龄不符合准入政策(22-60 周岁)")); Assert.assertTrue(decision.getScoreDetails().isEmpty()); }}在工程根目录执行以下命令运行本案例:
mvn test -Dtest=RiskAdmissionServiceTest测试覆盖形态
Section titled “测试覆盖形态”测试类共 6 个 @Test 方法,覆盖自动通过、拒绝、人工复核、降级、一票否决、前置硬拒绝六类真实业务形态:
| 测试方法 | 构造的业务形态 | 关键断言/期望决策 |
|---|---|---|
shouldPassPrimeCustomerWithTopScore |
35 岁国企、在职 84 个月、社保 60 个月、月入 30000 负债 4500、无逾期、查询 2 次、房抵估值 60 万借 30 万的优质客户 | PASS,总分 100,等级 A,额度 300000.0,利率 0.0385,拒绝原因与风险提示均为空,评分明细 6 条 |
shouldRejectHighRiskCustomer |
31 岁自雇、无社保、负债率 75%、6 次逾期最长 120 天、查询 12 次、无抵押的高风险客户 | REJECT,总分 0,等级 D,额度 0.0,利率 0.0,2 条拒绝原因:负债收入比过高、历史逾期严重最长 120 天 |
shouldRouteBorderlineCustomerToManualReview |
28 岁民营、在职 20 个月、负债率 46%、1 次 20 天逾期、查询 4 次、车抵 13 万借 10 万的边界客户 | MANUAL_REVIEW,总分 57,等级 C,额度 100000.0,利率 0.0680 |
shouldEscalateHighScoreCustomerWithFrequentQueries |
40 岁上市公司、模型 85 分 A 级,但近 3 月查询 10 次的多头借贷客户 | MANUAL_REVIEW(由 PASS 自动降级),额度 200000.0,风险提示包含「转人工复核」与「查询 10 次存在多头借贷迹象」 |
shouldRejectCustomerWithJudicialRecord |
模型评分 92 分、A 级,但存在涉诉/被执行记录的客户 | REJECT,总分仍记录为 92、等级 A,但额度 0.0、利率 0.0,拒绝原因含「存在涉诉/被执行记录,触发一票否决」 |
shouldRejectUnderageApplicantBeforeScoring |
19 周岁申请人,其余字段正常 | REJECT,总分 0,额度 0.0,拒绝原因「年龄不符合准入政策(22-60 周岁)」,评分明细为空(未进模型) |
优质国企客户(月收入 30000、月负债 4500、无逾期、房产抵押估值 60 万):
准入结论: PASS 总分: 100 风险等级: A建议额度: 300000.0 执行利率: 0.0385评分明细: 负债收入比 25 | 逾期 30 | 职业稳定性 15 | 征信查询 10 | 抵押物 20高风险自雇客户(负债率 75%、6 次逾期且最长 120 天、近 3 月查询 12 次、无抵押):
准入结论: REJECT 总分: 0 风险等级: D拒绝原因: 负债收入比过高,超出可承受范围 历史逾期严重,最长逾期天数 120 天- 评分口径与定价全部外置 XML,支持热更新:负债率分档、逾期矩阵、等级线 85/70/55、额度倍数 30/24/18/12、利率 0.0385/0.0495/0.0680 都在
risk-admission.xml中,调政策只改 XML、重建代理即生效,无需重新编译发版。 - 多维评分一方法一口径,独立调参与审计:5 个评分方法互不依赖,Service 逐维调用并把「维度分 / 满分」写入评分明细,每笔决策都能还原各维度贡献,满足风控模型回溯与监管审计要求。
- Java 侧兜底机制完整:总分在 Service 中 clamp 到 0~100,避免征信查询负分拉穿总分;建议额度同时受 50 万政策上限与申请金额双重封顶,并用
BigDecimal保留两位小数。 - 一票否决与前置硬规则短路:涉诉记录在规则侧
admit中优先于分数判定(92 分仍拒);年龄、收入、金额在 Java 侧前置校验,命中即返回、不消耗评分调用。 - 自动降级二次判定:
PASS结论还会再过一次needManualReview,高分但多头查询客户被改判MANUAL_REVIEW并追加风险提示,横断策略集中且可测。 @Param按名绑定 + 代理复用 + ASM 调用链:规则方法只接收标量参数,复杂进件对象由 Service 拆解传入;代理实例在@Before中一次创建、6 个用例复用;引擎基于 Java 8、无重型依赖,可直接嵌入老旧信贷核心与信创 JDK 环境。