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.
Industry scenario
Section titled “Industry scenario”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.
Business pain points
Section titled “Business pain points”- 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-elseblocks 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.
Solution architecture
Section titled “Solution architecture”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 |
Rule method breakdown
Section titled “Rule method breakdown”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 |
Code implementation (complete & runnable)
Section titled “Code implementation (complete & runnable)”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.
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()); }}Run & verified results
Section titled “Run & verified results”Run this case with the following command at the project root:
mvn test -Dtest=RiskAdmissionServiceTestTest coverage
Section titled “Test coverage”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) |
Verified output
Section titled “Verified output”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 | 抵押物 20High-risk self-employed customer (75% debt ratio, 6 overdues with 120 max days, 12 inquiries in 3 months, no collateral):
准入结论: REJECT 总分: 0 风险等级: D拒绝原因: 负债收入比过高,超出可承受范围 历史逾期严重,最长逾期天数 120 天Technical highlights
Section titled “Technical highlights”- 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
PASSverdict goes throughneedManualReviewonce more; high-score but multi-inquiry customers are re-judged asMANUAL_REVIEWwith an appended risk warning — cross-cutting policy centralized and testable. @Paramname 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@Beforeand 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.