跳转到内容

案例 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

规则接口共 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

将六个文件按源码原有包结构放入 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 个用例。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 信贷授信申请件(进件要素)
* <p>
* 字段来源于真实个人消费信贷进件表单与征信报告摘要,仅保留风控评分所需的关键要素。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public 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;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 授信审批决策结果
*/
@Data
public 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);
}
}
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);
}
<?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>
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();
}
}
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());
}
}

在工程根目录执行以下命令运行本案例:

Terminal window
mvn test -Dtest=RiskAdmissionServiceTest

测试类共 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 环境。