Skip to content

Case 1: Consumer credit risk admission

This case corresponds to Scenario 1: Consumer credit risk admission (production-grade template) in the JQuick-Java repository’s USE-CASES.md, in the financial risk control domain. It automates the approval decision for a personal consumer credit application: run upfront hard checks, aggregate a five-dimension scorecard, map the risk grade, and output the admission verdict together with a suggested credit limit and the annualized execution interest rate.

Personal consumer credit application approval is the core entry point of the credit factory at banks and consumer finance companies. An application carries elements such as employer type, months with the current employer, consecutive months of social security contributions, monthly income and monthly debt repayment, overdue counts and the longest overdue days in the last 2 years, credit inquiry counts in the last 3 months, collateral type and appraised value, litigation/enforcement records, and city tier — all sourced from real application forms and credit report summaries.

The approval decision uses a “multi-dimensional scorecard + decision mapping” structure: five dimensions are scored independently — debt-to-income ratio (0–25 points), overdue history (0–30), employment stability (0–15), credit inquiries (-5–10), and collateral (0–20). The orchestration layer aggregates them into a 0–100 total risk score, which is then mapped to an A/B/C/D risk grade, a PASS/MANUAL_REVIEW/REJECT admission verdict, a suggested credit limit computed as monthly income times a risk coefficient, and an annualized execution interest rate tied to the risk grade.

The final decision output is more than a verdict: it also includes per-dimension score details, a rejection reason list, and a risk warning list. High-risk customers get concrete rejection reasons such as “debt-to-income ratio too high” or “severe overdue history, longest overdue N days”; approved customers still get risk signals such as multi-platform borrowing, insufficient social security contributions, unsecured credit-only lending, and regional concentration — for manual review by credit officers and for model backtracking.

In addition, age (22–60), income validity, and application amount validity are upfront hard rules that do not enter the scoring model — a hit rejects the application immediately. Litigation/enforcement records are a one-vote veto outside scoring: even a 92-point grade-A model score must be rejected. And a high-score customer with too many recent credit inquiries (a multi-platform borrowing signal) is automatically downgraded from PASS to manual review.

  • Scoring thresholds scattered in hard code: the 0.2/0.4/0.5/0.6 debt-ratio tier lines, the overdue-count × longest-overdue-days cross matrix, and the 85/70/55 grade and admission lines are often scattered across if-else blocks in branch, online lending, credit card, and other intake channels, making a consistent policy hard to maintain.
  • Policy adjustments require a new release: credit limit multipliers (30/24/18/12 times monthly income) and per-grade execution rates (3.85%/4.95%/6.80%) change frequently with regulatory requirements and market windows; under hard coding, every tuning goes through the full compile-and-release cycle.
  • Coupled multi-dimensional scoring — hard to tune or backtrack per dimension: when five dimensions live in one big method, adjusting a single dimension (such as the credit inquiry policy) requires regressing the whole chain, and for a rejected loan no one can tell how many points each dimension contributed — model iteration lacks evidence.
  • Unexplainable, unauditable decisions: with only “approve/reject” verdicts and no rejection reasons, risk warnings, or score details, the basis of a decision cannot be reconstructed for manual review, customer complaints, or regulatory inspection.
  • Cross-cutting logic is easy to get wrong: age-based hard rejection, litigation one-vote veto, and downgrading high-score multi-inquiry customers to manual review are mixed into the scoring flow; boundary shapes (e.g., 92 points but litigated, 85 points but 10 inquiries) are the most error-prone.
  • Legacy cores and domestic (Xinchuang) environments resist heavy platforms: many credit core systems still run on JDK 8 or domestic JDKs; heavy rule platforms like Drools are costly to upgrade, heavy to operate, and hard to embed into high-frequency intake pipelines.

This case uses the six-file structure of “domain model + rule interface & XML rule script + orchestration service + tests”: all rule policies are externalized in XML, while the Java side keeps only upfront validation, score aggregation and capping, limit capping, and decision assembly.

Layer Responsibility Files
Domain model Application elements, admission decision result CreditApplication.java, AdmissionDecision.java
Rule set & XML 5 scoring dimensions + 5 decision mappings, all policies externalized RiskAdmissionRules.java, risk-admission.xml
Orchestration service Upfront validation, dimension aggregation, limit capping, rejection reason and risk warning assembly RiskAdmissionService.java
Tests 6 groups of realistic business shapes RiskAdmissionServiceTest.java

The rule interface has 10 methods in total. Scoring methods each own one dimension, decision methods only do mapping, and each method does exactly one thing — enabling per-dimension tuning and audit backtracking:

Rule method Policy Score / output
scoreDebtRatio Monthly debt repayment / monthly income, tiered at 0.2, 0.4, 0.5, 0.6 0 ~ 25
scoreOverdue Overdue count × longest overdue days cross-tiering 0 ~ 30
scoreEmployment Months-employed tiers + employer type (SOE / listed / foreign / private) overlay 0 ~ 15
scoreCreditQuery Credit inquiry count in the last 3 months; multi-platform borrowing signal deducts points -5 ~ 10
scoreCollateral Collateral type (house / vehicle) + value coverage multiple 0 ~ 20
riskLevel Total score mapped to A / B / C / D (85/70/55 lines) -
admit Total score + litigation one-vote veto, mapped to PASS / MANUAL_REVIEW / REJECT -
suggestLimit Monthly income × risk coefficient (30/24/18/12/0 times) Suggested limit (yuan)
interestRate Risk grade mapped to annualized execution rate 0.0385 / 0.0495 / 0.0680 / 0
needManualReview Total score in the 55–70 middle band or inquiry count above 8 → manual review boolean

Place the six files into the corresponding src/test directories of a Maven project following the original package structure: model classes CreditApplication.java and AdmissionDecision.java under com.github.paohaijiao.demo.model; the rule interface RiskAdmissionRules.java and test class RiskAdmissionServiceTest.java under com.github.paohaijiao.demo; the orchestration service RiskAdmissionService.java under com.github.paohaijiao.demo.service; and the rule script risk-admission.xml under src/test/resources/demo/. Keep the package names exactly as in the source; the model classes depend on Lombok’s @Data annotation. Run mvn test -Dtest=RiskAdmissionServiceTest at the project root to execute all 6 test cases.

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());
}
}

Run this case with the following command at the project root:

Terminal window
mvn test -Dtest=RiskAdmissionServiceTest

The test class has 6 @Test methods covering six realistic business shapes: auto-pass, rejection, manual review, downgrade, one-vote veto, and upfront hard rejection:

Test method Constructed business shape Key assertions / expected decision
shouldPassPrimeCustomerWithTopScore A prime customer: 35 years old, SOE employer, 84 months employed, 60 months of social security, monthly income 30000 with debt 4500, no overdue, 2 inquiries, house collateral appraised at 600k for a 300k loan PASS, total score 100, grade A, limit 300000.0, rate 0.0385, empty rejection reasons and risk warnings, 6 score detail entries
shouldRejectHighRiskCustomer A high-risk customer: 31 years old, self-employed, no social security, 75% debt ratio, 6 overdues with 120 max days, 12 inquiries, no collateral REJECT, total score 0, grade D, limit 0.0, rate 0.0, 2 rejection reasons: debt-to-income too high and severe overdue history (max 120 days)
shouldRouteBorderlineCustomerToManualReview A borderline customer: 28 years old, private employer, 20 months employed, 46% debt ratio, one 20-day overdue, 4 inquiries, vehicle collateral of 130k for a 100k loan MANUAL_REVIEW, total score 57, grade C, limit 100000.0, rate 0.0680
shouldEscalateHighScoreCustomerWithFrequentQueries A multi-platform borrowing customer: 40 years old, listed company, model score 85 grade A, but 10 inquiries in the last 3 months MANUAL_REVIEW (auto-downgraded from PASS), limit 200000.0, risk warnings include “routed to manual review” and “10 inquiries, multi-platform borrowing signs”
shouldRejectCustomerWithJudicialRecord A customer with model score 92, grade A, but with litigation/enforcement records REJECT, total score still recorded as 92 and grade A, but limit 0.0 and rate 0.0, rejection reason includes “litigation/enforcement record triggers one-vote veto”
shouldRejectUnderageApplicantBeforeScoring A 19-year-old applicant with otherwise normal fields REJECT, total score 0, limit 0.0, rejection reason “age outside admission policy (22–60)”, empty score details (never entered the model)

Prime SOE customer (monthly income 30000, monthly debt 4500, no overdue, house collateral appraised at 600k):

准入结论: PASS 总分: 100 风险等级: A
建议额度: 300000.0 执行利率: 0.0385
评分明细: 负债收入比 25 | 逾期 30 | 职业稳定性 15 | 征信查询 10 | 抵押物 20

High-risk self-employed customer (75% debt ratio, 6 overdues with 120 max days, 12 inquiries in 3 months, no collateral):

准入结论: REJECT 总分: 0 风险等级: D
拒绝原因: 负债收入比过高,超出可承受范围
历史逾期严重,最长逾期天数 120 天
  • All scoring policies and pricing externalized in XML, hot-updatable: the debt-ratio tiers, overdue matrix, grade lines 85/70/55, limit multipliers 30/24/18/12, and rates 0.0385/0.0495/0.0680 all live in risk-admission.xml — adjusting policy only requires editing the XML and rebuilding the proxy, no recompilation or release.
  • One method per scoring dimension, independently tunable and auditable: the 5 scoring methods are independent; the Service calls them dimension by dimension and writes “dimension score / max” into the score details, so every decision can be decomposed into per-dimension contributions, meeting risk-model backtracking and regulatory audit requirements.
  • Complete Java-side guardrails: the total score is clamped to 0–100 in the Service so the negative inquiry score cannot drag it below zero; the suggested limit is double-capped by the 500k policy ceiling and the requested amount, and rounded to two decimals with BigDecimal.
  • One-vote veto and upfront hard-rule short-circuiting: litigation records take precedence over score in the rule-side admit (92 points still rejected); age, income, and amount are validated upfront on the Java side, returning immediately without consuming scoring calls.
  • Automatic downgrade as a second-pass check: a PASS verdict goes through needManualReview once more; high-score but multi-inquiry customers are re-judged as MANUAL_REVIEW with an appended risk warning — cross-cutting policy centralized and testable.
  • @Param name binding + proxy reuse + ASM invocation chain: rule methods only take scalar parameters; complex application objects are decomposed by the Service before being passed in; the proxy is created once in @Before and reused across all 6 cases; the engine targets Java 8 with no heavy dependencies and can be embedded directly into legacy credit cores and domestic (Xinchuang) JDK environments.