案例 15:银行卡交易反欺诈
本案例对应 JQuick-Java 仓库 USE-CASES.md 中的场景 15,面向实时风控领域的银行卡支付反欺诈。系统对每笔交易快照从金额、短时频次、地理位置、交易时段、设备、商户、无卡交易、鉴权失败、行为偏离/新账户九个风险维度独立计分,汇总后输出风险分并决定拦截(BLOCK)、强验证(STEP_UP,短信/人脸)或放行(PASS);受信商户白名单在评分前短路放行,中风险境外交易与高风险交易实时预警,全部计分与路由口径由 JQuick-Java 的 XML 规则代理驱动。
银行卡支付链路要求毫秒级风控决策。授权请求带来交易流水号、脱敏卡号、金额、交易小时(0-23)、渠道(POS / ATM / ONLINE / MOBILE)、是否境外/跨城市/报备出行、近 1 小时交易笔数与累计金额、是否新商户、是否更换设备、是否有卡交易、近 24 小时鉴权失败次数、账户开立天数、历史单笔最大金额,以及商户是否受信(白名单)。
决策入口首先是白名单短路:受信商户(trustedMerchant)交易直接给出 0 分、LOW、PASS,指标明细只留一条「受信商户白名单交易,直接放行」,九维评分完全不执行,保障水电缴费、大型平台等可信收款方的成功率与响应时延。
非白名单交易进入九维独立评分:金额按 ≥5 万/2 万/1 万/5 千给 30/20/12/6;频次维度先按 1 小时 ≥10/≥5/≥3 笔给 25/15/8,再叠加短时累计金额 ≥10 万/5 万的 +15/+8,维度内封顶 25;地理位置境外 25、异地未报备 15、已报备出行仅 5;时段 23 时-5 时 12 分、22 时与 6 时 6 分;设备变更 15;新商户 8;线上/移动无卡 10;24 小时鉴权失败 ≥3 次 10、≥1 次 5;行为偏离维度对金额达历史 5 倍/3 倍加 15/8,开户不足 7 天再 +15。
九维汇总后由 Service clamp 到 0-100,再映射风险等级与处置:≥75 为 HIGH 拦截,≥45 为 MEDIUM 强验证(短信/人脸),其余 LOW 放行。预警规则与处置并行:HIGH 必预警,MEDIUM 且境外交易预警。决策结果还携带按交易要素逐条组装的风险指标明细(境外交易、深夜时段、设备变更、无卡交易、金额偏离历史等),供风控后台核查与客服解释使用。
- 风险因子多且口径变化快:九个维度各自有金额档、笔数档、时段边界,风控运营调阈值频繁,硬编码在授权服务里每次调参都要走核心系统发版。
- 白名单必须最先执行且零误伤:受信商户交易即使夜间、无卡也应放行,若白名单判断混在评分之后,会叠加出高分误拦截,影响大客户交易成功率。
- 多维分数叠加易超过理论上限:九维满分之和超过 100,深夜境外大额用例原始分 126,不在 Java 侧 clamp 就会出现 126 分这类非法分值,等级阈值也随之失真。
- 倍数比较存在浮点误差隐患:金额偏离历史 3 倍/5 倍若用
amount / historicalMax >= 5.0浮点比较,边界值可能因精度抖动错档。 - 频次维度是复合计分还要内部封顶:笔数档与短时累计金额档要相加,但维度贡献不能超过 25,这种「分档叠加 + 维度封顶」用普通配置表难以表达。
- 预警与处置是两条规则线且要求可审计:中风险境外要预警但中风险境内不强提醒,每笔拦截/强验证都必须能列出命中的风险指标;不少银行核心与支付网控仍在 JDK 8、信创环境,无法依赖重型实时规则平台。
本案例采用「入参模型 + 决策结果模型 + 规则接口 + XML 规则脚本 + Service 编排 + JUnit 测试」六件套结构,九维计分、等级阈值、处置动作、预警口径全部外置在 XML 中,Service 负责白名单短路、总分 clamp 与风险指标明细组装:
| 层次 | 职责 | 对应文件 |
|---|---|---|
| 领域模型 | 交易快照、风控决策(风险分/等级/处置/预警/指标明细) | BankTransaction.java、FraudDecision.java |
| 规则集与 XML | 九维风险因子 + 等级/处置/预警(12 个方法) | TransactionFraudRules.java、transaction-fraud.xml |
| 编排服务 | 白名单短路、九维汇总 0-100 clamp、风险指标明细组装 | TransactionFraudService.java |
| 测试 | 6 组交易形态(日常、高危、报备、强验证、新账户、白名单) | TransactionFraudServiceTest.java |
规则方法拆分
Section titled “规则方法拆分”规则接口共 12 个方法,九个风险因子各自独立计分,等级、处置、预警顺序路由:
| 规则方法 | 口径 | 分值/输出 |
|---|---|---|
scoreAmount |
金额 ≥50000 给 30 / ≥20000 给 20 / ≥10000 给 12 / ≥5000 给 6 / 否则 0 | 0 ~ 30 |
scoreVelocity |
1 小时笔数 ≥10/≥5/≥3 给 25/15/8;累计金额 ≥10 万 +15、≥5 万 +8;维度内封顶 25 | 0 ~ 25 |
scoreGeo |
境外 25;异地且未报备(travelMode == false)15;异地已报备 5;同城 0 | 0 ~ 25 |
scoreTime |
小时 ≥23 或 <5 给 12;≥22 或 ≤6 给 6;其余 0 |
0 ~ 12 |
scoreDevice |
设备变更给 15,否则 0 | 0 / 15 |
scoreMerchant |
新商户首笔给 8,否则 0 | 0 / 8 |
scoreCardNotPresent |
无卡且渠道为 ONLINE 或 MOBILE 给 10,否则 0 | 0 / 10 |
scoreFailure |
24h 鉴权失败 ≥3 次给 10 / ≥1 次给 5 / 否则 0 | 0 ~ 10 |
scoreDeviation |
历史最大 >0 时,金额达 5 倍给 15、达 3 倍给 8(交叉相乘比较);开户 <7 天再 +15 |
0 ~ 30 |
riskLevel |
≥75 HIGH / ≥45 MEDIUM / 其余 LOW | 等级字符串 |
fraudAction |
HIGH→BLOCK;MEDIUM→STEP_UP;LOW→PASS | 处置字符串 |
needAlert |
HIGH 必预警;MEDIUM 且境外交易预警;其余不预警 | boolean |
代码实现(完整可运行)
Section titled “代码实现(完整可运行)”将以下六个文件按源码原包名放入 JQuick-Java 的测试工程:两个模型放在 com.github.paohaijiao.demo.model,规则接口与测试类放在 com.github.paohaijiao.demo,编排服务放在 com.github.paohaijiao.demo.service,XML 脚本放入 src/test/resources/demo/。保持目录与包名不变后,在工程根目录执行 mvn test -Dtest=TransactionFraudServiceTest 即可运行。
BankTransaction.java
Section titled “BankTransaction.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 银行卡交易要素(实时风控所需的交易快照) */@Data@NoArgsConstructor@AllArgsConstructorpublic class BankTransaction {
/** * 交易流水号 */ private String txnNo;
/** * 银行卡号(脱敏) */ private String cardNo;
/** * 交易金额 */ private double amount;
/** * 交易小时(0-23) */ private int txnHour;
/** * 交易渠道:POS / ATM / ONLINE / MOBILE */ private String channel;
/** * 是否境外交易 */ private boolean overseas;
/** * 是否跨城市交易 */ private boolean crossCity;
/** * 是否报备出行模式 */ private boolean travelMode;
/** * 近 1 小时交易笔数 */ private int txnCount1h;
/** * 近 1 小时累计交易金额 */ private double txnAmount1h;
/** * 是否新商户 */ private boolean newMerchant;
/** * 是否更换设备 */ private boolean deviceChanged;
/** * 是否有卡交易(刷卡/插卡) */ private boolean cardPresent;
/** * 近 24 小时鉴权失败次数 */ private int failCount24h;
/** * 账户开立天数 */ private int accountAgeDays;
/** * 历史单笔最大金额 */ private double historicalMaxAmount;
/** * 是否受信商户(白名单) */ private boolean trustedMerchant;}FraudDecision.java
Section titled “FraudDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 银行卡交易风控决策结果 */@Datapublic class FraudDecision {
/** * 交易流水号 */ private String txnNo;
/** * 风险分(0-100) */ private int riskScore;
/** * 风险等级:HIGH / MEDIUM / LOW */ private String riskLevel;
/** * 处置动作:BLOCK-拦截 / STEP_UP-强验证 / PASS-放行 */ private String action;
/** * 是否实时预警 */ private boolean needAlert;
/** * 命中风险指标 */ private List<String> indicators = new ArrayList<String>();
public void addIndicator(String indicator) { this.indicators.add(indicator); }}TransactionFraudRules.java
Section titled “TransactionFraudRules.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/** * 场景 15:银行卡交易反欺诈规则集 * <p> * 九维风险因子独立计分,汇总后决定拦截、强验证或放行: * 金额、频次、地理位置、时段、设备、商户、无卡交易、鉴权失败、行为偏离与新账户。 * 规则脚本见 {@code src/test/resources/demo/transaction-fraud.xml}。 */public interface TransactionFraudRules {
int scoreAmount(@Param("amount") double amount);
int scoreVelocity(@Param("txnCount1h") int txnCount1h, @Param("txnAmount1h") double txnAmount1h);
int scoreGeo(@Param("overseas") boolean overseas, @Param("crossCity") boolean crossCity, @Param("travelMode") boolean travelMode);
int scoreTime(@Param("txnHour") int txnHour);
int scoreDevice(@Param("deviceChanged") boolean deviceChanged);
int scoreMerchant(@Param("newMerchant") boolean newMerchant);
int scoreCardNotPresent(@Param("cardNotPresent") boolean cardNotPresent, @Param("channel") String channel);
int scoreFailure(@Param("failCount24h") int failCount24h);
int scoreDeviation(@Param("amount") double amount, @Param("historicalMaxAmount") double historicalMaxAmount, @Param("accountAgeDays") int accountAgeDays);
String riskLevel(@Param("riskScore") int riskScore);
String fraudAction(@Param("riskLevel") String riskLevel);
boolean needAlert(@Param("riskLevel") String riskLevel, @Param("overseas") boolean overseas);}transaction-fraud.xml
Section titled “transaction-fraud.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.TransactionFraudRules">
<!-- 单笔金额评分 --> <java name="scoreAmount" returnClass="int"> <![CDATA[ int def scoreAmount(double:amount) { if (amount >= 50000.0) { return 30; } else if (amount >= 20000.0) { return 20; } else if (amount >= 10000.0) { return 12; } else if (amount >= 5000.0) { return 6; } else { return 0; } } ]]> </java>
<!-- 交易频次评分:笔数档位 + 短时累计金额,封顶 25 --> <java name="scoreVelocity" returnClass="int"> <![CDATA[ int def scoreVelocity(int:txnCount1h, double:txnAmount1h) { int score = 0; if (txnCount1h >= 10) { score = 25; } else if (txnCount1h >= 5) { score = 15; } else if (txnCount1h >= 3) { score = 8; } if (txnAmount1h >= 100000.0) { score = score + 15; } else if (txnAmount1h >= 50000.0) { score = score + 8; } if (score > 25) { score = 25; } return score; } ]]> </java>
<!-- 地理位置评分:境外 25,异地未报备 15,报备出行 5 --> <java name="scoreGeo" returnClass="int"> <![CDATA[ int def scoreGeo(boolean:overseas, boolean:crossCity, boolean:travelMode) { if (overseas) { return 25; } else if (crossCity && travelMode == false) { return 15; } else if (crossCity) { return 5; } else { return 0; } } ]]> </java>
<!-- 交易时段评分:深夜 12,夜间 6 --> <java name="scoreTime" returnClass="int"> <![CDATA[ int def scoreTime(int:txnHour) { if (txnHour >= 23 || txnHour < 5) { return 12; } else if (txnHour >= 22 || txnHour <= 6) { return 6; } else { return 0; } } ]]> </java>
<!-- 设备更换评分 --> <java name="scoreDevice" returnClass="int"> <![CDATA[ int def scoreDevice(boolean:deviceChanged) { if (deviceChanged) { return 15; } else { return 0; } } ]]> </java>
<!-- 新商户评分 --> <java name="scoreMerchant" returnClass="int"> <![CDATA[ int def scoreMerchant(boolean:newMerchant) { if (newMerchant) { return 8; } else { return 0; } } ]]> </java>
<!-- 无卡交易评分:线上/移动端且无卡现场景加 10 --> <java name="scoreCardNotPresent" returnClass="int"> <![CDATA[ int def scoreCardNotPresent(boolean:cardNotPresent, str:channel) { if (cardNotPresent && (channel == "ONLINE" || channel == "MOBILE")) { return 10; } else { return 0; } } ]]> </java>
<!-- 鉴权失败评分 --> <java name="scoreFailure" returnClass="int"> <![CDATA[ int def scoreFailure(int:failCount24h) { if (failCount24h >= 3) { return 10; } else if (failCount24h >= 1) { return 5; } else { return 0; } } ]]> </java>
<!-- 行为偏离与新账户评分:金额偏离历史 3 倍/5 倍,开户不足 7 天加 15 --> <java name="scoreDeviation" returnClass="int"> <![CDATA[ int def scoreDeviation(double:amount, double:historicalMaxAmount, int:accountAgeDays) { int score = 0; if (historicalMaxAmount > 0.0) { if (amount * 10 >= historicalMaxAmount * 50) { score = 15; } else if (amount * 10 >= historicalMaxAmount * 30) { score = 8; } } if (accountAgeDays < 7) { score = score + 15; } return score; } ]]> </java>
<!-- 风险等级 --> <java name="riskLevel" returnClass="java.lang.String"> <![CDATA[ str def riskLevel(int:riskScore) { if (riskScore >= 75) { return "HIGH"; } else if (riskScore >= 45) { return "MEDIUM"; } else { return "LOW"; } } ]]> </java>
<!-- 处置动作 --> <java name="fraudAction" returnClass="java.lang.String"> <![CDATA[ str def fraudAction(str:riskLevel) { if (riskLevel == "HIGH") { return "BLOCK"; } else if (riskLevel == "MEDIUM") { return "STEP_UP"; } else { return "PASS"; } } ]]> </java>
<!-- 实时预警:高风险必预警,中风险境外交易预警 --> <java name="needAlert" returnClass="boolean"> <![CDATA[ boolean def needAlert(str:riskLevel, boolean:overseas) { if (riskLevel == "HIGH") { return true; } else if (riskLevel == "MEDIUM" && overseas) { return true; } else { return false; } } ]]> </java>
</javas>TransactionFraudService.java
Section titled “TransactionFraudService.java”package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.TransactionFraudRules;import com.github.paohaijiao.demo.model.BankTransaction;import com.github.paohaijiao.demo.model.FraudDecision;
/** * 场景 15:银行卡交易反欺诈实时风控服务 * <p> * 职责划分: * <ul> * <li><b>Java 侧</b>:九维风险分汇总与封顶、白名单短路、风险指标明细组装</li> * <li><b>规则侧</b>:各风险因子计分口径、等级阈值、处置与预警路由,全部外置在 XML 中</li> * </ul> */public class TransactionFraudService {
private static final int SCORE_FLOOR = 0; private static final int SCORE_CEIL = 100;
private final TransactionFraudRules rules;
public TransactionFraudService(TransactionFraudRules rules) { this.rules = rules; }
/** * 对单笔交易进行实时风险评估 * * @param txn 交易要素 * @return 风控决策 */ public FraudDecision evaluate(BankTransaction txn) { FraudDecision decision = new FraudDecision(); decision.setTxnNo(txn.getTxnNo());
// 受信商户白名单直接放行 if (txn.isTrustedMerchant()) { decision.setRiskScore(0); decision.setRiskLevel("LOW"); decision.setAction("PASS"); decision.setNeedAlert(false); decision.addIndicator("受信商户白名单交易,直接放行"); return decision; }
int rawScore = rules.scoreAmount(txn.getAmount()) + rules.scoreVelocity(txn.getTxnCount1h(), txn.getTxnAmount1h()) + rules.scoreGeo(txn.isOverseas(), txn.isCrossCity(), txn.isTravelMode()) + rules.scoreTime(txn.getTxnHour()) + rules.scoreDevice(txn.isDeviceChanged()) + rules.scoreMerchant(txn.isNewMerchant()) + rules.scoreCardNotPresent(!txn.isCardPresent(), txn.getChannel()) + rules.scoreFailure(txn.getFailCount24h()) + rules.scoreDeviation(txn.getAmount(), txn.getHistoricalMaxAmount(), txn.getAccountAgeDays()); int riskScore = clamp(rawScore);
String riskLevel = rules.riskLevel(riskScore); decision.setRiskScore(riskScore); decision.setRiskLevel(riskLevel); decision.setAction(rules.fraudAction(riskLevel)); decision.setNeedAlert(rules.needAlert(riskLevel, txn.isOverseas()));
buildIndicators(txn, decision); return decision; }
private int clamp(int score) { if (score < SCORE_FLOOR) { return SCORE_FLOOR; } if (score > SCORE_CEIL) { return SCORE_CEIL; } return score; }
private void buildIndicators(BankTransaction txn, FraudDecision decision) { if (txn.getAmount() >= 5000.0) { decision.addIndicator("大额交易 " + txn.getAmount() + " 元"); } if (txn.getTxnCount1h() >= 3) { decision.addIndicator("近 1 小时交易 " + txn.getTxnCount1h() + " 笔"); } if (txn.getTxnAmount1h() >= 50000.0) { decision.addIndicator("近 1 小时累计交易 " + txn.getTxnAmount1h() + " 元"); } if (txn.isOverseas()) { decision.addIndicator("境外交易"); } else if (txn.isCrossCity()) { decision.addIndicator(txn.isTravelMode() ? "异地交易(已报备出行)" : "异地未报备交易"); } if (txn.getTxnHour() >= 23 || txn.getTxnHour() < 5) { decision.addIndicator("深夜时段交易(" + txn.getTxnHour() + " 时)"); } else if (txn.getTxnHour() >= 22 || txn.getTxnHour() <= 6) { decision.addIndicator("夜间时段交易(" + txn.getTxnHour() + " 时)"); } if (txn.isDeviceChanged()) { decision.addIndicator("交易设备变更"); } if (txn.isNewMerchant()) { decision.addIndicator("新商户首笔交易"); } if (!txn.isCardPresent() && ("ONLINE".equals(txn.getChannel()) || "MOBILE".equals(txn.getChannel()))) { decision.addIndicator(txn.getChannel() + " 渠道无卡交易"); } if (txn.getFailCount24h() >= 1) { decision.addIndicator("近 24 小时鉴权失败 " + txn.getFailCount24h() + " 次"); } if (txn.getHistoricalMaxAmount() > 0.0 && txn.getAmount() * 10 >= txn.getHistoricalMaxAmount() * 30) { decision.addIndicator("金额显著偏离历史消费习惯"); } if (txn.getAccountAgeDays() < 7) { decision.addIndicator("新开户账户(" + txn.getAccountAgeDays() + " 天)"); } }}TransactionFraudServiceTest.java
Section titled “TransactionFraudServiceTest.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.BankTransaction;import com.github.paohaijiao.demo.model.FraudDecision;import com.github.paohaijiao.demo.service.TransactionFraudService;import com.github.paohaijiao.xml.JQuickJava;import org.junit.Assert;import org.junit.Before;import org.junit.Test;
/** * 场景 15 生产级样板:银行卡交易反欺诈实时风控 * <p> * 覆盖 6 组交易形态:日常正常交易、深夜境外高危、异地报备出行、 * 夜间新设备中风险、新账户高频失败、受信商户白名单。 */public class TransactionFraudServiceTest {
private TransactionFraudService service;
@Before public void setUp() { TransactionFraudRules rules = JQuickJava.create() .createApi(TransactionFraudRules.class, "demo/transaction-fraud.xml"); this.service = new TransactionFraudService(rules); }
/** * 日常小额 POS 交易:全部风险因子为 0 → 放行 */ @Test public void shouldPassNormalTransaction() { BankTransaction txn = txn("TXN-1501", 200.0, 14, "POS", false, false, false, 1, 200.0, false, false, true, 0, 400, 5000.0, false);
FraudDecision decision = service.evaluate(txn);
Assert.assertEquals(0, decision.getRiskScore()); Assert.assertEquals("LOW", decision.getRiskLevel()); Assert.assertEquals("PASS", decision.getAction()); Assert.assertFalse(decision.isNeedAlert()); Assert.assertTrue(decision.getIndicators().isEmpty()); }
/** * 深夜境外线上大额交易,多因子叠加 → 100 分拦截并预警 */ @Test public void shouldBlockHighRiskOverseasNightTransaction() { BankTransaction txn = txn("TXN-1502", 30000.0, 2, "ONLINE", true, true, false, 6, 60000.0, true, true, false, 1, 400, 8000.0, false);
FraudDecision decision = service.evaluate(txn);
Assert.assertEquals(100, decision.getRiskScore()); Assert.assertEquals("HIGH", decision.getRiskLevel()); Assert.assertEquals("BLOCK", decision.getAction()); Assert.assertTrue(decision.isNeedAlert()); Assert.assertTrue(decision.getIndicators().contains("境外交易")); Assert.assertTrue(decision.getIndicators().contains("深夜时段交易(2 时)")); Assert.assertTrue(decision.getIndicators().contains("交易设备变更")); Assert.assertTrue(decision.getIndicators().contains("ONLINE 渠道无卡交易")); Assert.assertTrue(decision.getIndicators().contains("金额显著偏离历史消费习惯")); }
/** * 异地报备出行的移动端无卡小额交易 → 29 分放行 */ @Test public void shouldPassReportedTravelModeTransaction() { BankTransaction txn = txn("TXN-1503", 6000.0, 20, "MOBILE", false, true, true, 2, 8000.0, true, false, false, 0, 200, 5000.0, false);
FraudDecision decision = service.evaluate(txn);
Assert.assertEquals(29, decision.getRiskScore()); Assert.assertEquals("LOW", decision.getRiskLevel()); Assert.assertEquals("PASS", decision.getAction()); Assert.assertFalse(decision.isNeedAlert()); Assert.assertTrue(decision.getIndicators().contains("异地交易(已报备出行)")); Assert.assertTrue(decision.getIndicators().contains("MOBILE 渠道无卡交易")); }
/** * 深夜新设备新商户线上交易 → 65 分中风险,强验证不预警 */ @Test public void shouldStepUpMediumRiskNightTransaction() { BankTransaction txn = txn("TXN-1504", 12000.0, 23, "ONLINE", false, false, false, 4, 30000.0, true, true, false, 0, 100, 8000.0, false);
FraudDecision decision = service.evaluate(txn);
Assert.assertEquals(65, decision.getRiskScore()); Assert.assertEquals("MEDIUM", decision.getRiskLevel()); Assert.assertEquals("STEP_UP", decision.getAction()); Assert.assertFalse(decision.isNeedAlert()); Assert.assertTrue(decision.getIndicators().contains("近 1 小时交易 4 笔")); Assert.assertTrue(decision.getIndicators().contains("深夜时段交易(23 时)")); }
/** * 新开户 3 天、鉴权连续失败、大额偏离 → 72 分中风险强验证 */ @Test public void shouldStepUpNewAccountWithAuthFailures() { BankTransaction txn = txn("TXN-1505", 6000.0, 15, "ONLINE", false, false, false, 4, 20000.0, true, false, false, 4, 3, 1000.0, false);
FraudDecision decision = service.evaluate(txn);
Assert.assertEquals(72, decision.getRiskScore()); Assert.assertEquals("MEDIUM", decision.getRiskLevel()); Assert.assertEquals("STEP_UP", decision.getAction()); Assert.assertTrue(decision.getIndicators().contains("新开户账户(3 天)")); Assert.assertTrue(decision.getIndicators().contains("近 24 小时鉴权失败 4 次")); }
/** * 虽命中多项风险因子,但商户在白名单 → 直接放行 */ @Test public void shouldBypassForTrustedMerchant() { BankTransaction txn = txn("TXN-1506", 40000.0, 1, "ONLINE", true, true, false, 8, 80000.0, true, true, false, 3, 2, 3000.0, true);
FraudDecision decision = service.evaluate(txn);
Assert.assertEquals(0, decision.getRiskScore()); Assert.assertEquals("LOW", decision.getRiskLevel()); Assert.assertEquals("PASS", decision.getAction()); Assert.assertFalse(decision.isNeedAlert()); Assert.assertEquals(1, decision.getIndicators().size()); Assert.assertTrue(decision.getIndicators().contains("受信商户白名单交易,直接放行")); }
private BankTransaction txn(String txnNo, double amount, int hour, String channel, boolean overseas, boolean crossCity, boolean travelMode, int count1h, double amount1h, boolean newMerchant, boolean deviceChanged, boolean cardPresent, int failCount24h, int accountAgeDays, double historicalMax, boolean trustedMerchant) { BankTransaction txn = new BankTransaction(); txn.setTxnNo(txnNo); txn.setCardNo("6222 **** **** 0001"); txn.setAmount(amount); txn.setTxnHour(hour); txn.setChannel(channel); txn.setOverseas(overseas); txn.setCrossCity(crossCity); txn.setTravelMode(travelMode); txn.setTxnCount1h(count1h); txn.setTxnAmount1h(amount1h); txn.setNewMerchant(newMerchant); txn.setDeviceChanged(deviceChanged); txn.setCardPresent(cardPresent); txn.setFailCount24h(failCount24h); txn.setAccountAgeDays(accountAgeDays); txn.setHistoricalMaxAmount(historicalMax); txn.setTrustedMerchant(trustedMerchant); return txn; }}在工程根目录执行:
mvn test -Dtest=TransactionFraudServiceTest测试覆盖形态
Section titled “测试覆盖形态”| 测试方法 | 构造的业务形态 | 关键断言/期望决策 |
|---|---|---|
shouldPassNormalTransaction |
14 时 POS 刷卡 200 元、老账户、无任何风险因子(TXN-1501) | 0 分、LOW、PASS、不预警、指标列表为空 |
shouldBlockHighRiskOverseasNightTransaction |
2 时境外线上 3 万、新设备、新商户、无卡、失败 1 次、偏离历史(TXN-1502) | 原始 126 分 clamp 为 100、HIGH、BLOCK、预警,含境外/深夜/设备/无卡/偏离 5 项指标 |
shouldPassReportedTravelModeTransaction |
20 时异地已报备、MOBILE 无卡 6000、新商户(TXN-1503) | 29 分(6+5+8+10)、LOW、PASS、不预警,含报备出行与 MOBILE 无卡指标 |
shouldStepUpMediumRiskNightTransaction |
23 时线上 1.2 万、新设备、新商户、1 小时 4 笔(TXN-1504) | 65 分、MEDIUM、STEP_UP、境内不预警,含 4 笔/深夜指标 |
shouldStepUpNewAccountWithAuthFailures |
开户 3 天、24h 失败 4 次、6000 元偏离历史 1000 元、新商户无卡(TXN-1505) | 72 分、MEDIUM、STEP_UP,含新开户 3 天与鉴权失败 4 次指标 |
shouldBypassForTrustedMerchant |
凌晨境外线上 4 万、8 笔/8 万、失败 3 次等多因子,但商户受信(TXN-1506) | 白名单短路:0 分、LOW、PASS、不预警,仅 1 条白名单指标 |
深夜境外线上大额(2 时、3 万、境外、新设备、无卡、1 次失败,9 因子叠加):
风险分: 100 (HIGH) 处置: BLOCK 实时预警: true指标: 境外交易 | 深夜时段 | 设备变更 | 无卡交易 | 金额偏离历史……受信商户白名单(命中多项风险因子但商户受信):
风险分: 0 (LOW) 处置: PASS 指标: 受信商户白名单交易,直接放行- 白名单在 Java 侧最前短路:
evaluate第一个分支就是trustedMerchant,命中即写死 0/LOW/PASS 并 return,九个评分方法一次都不调用,既保证受信商户零误伤,又省掉规则调用开销,契合毫秒级授权链路。 - 九维评分一个方法一个口径,可独立调参:金额、频次、地理、时段、设备、商户、无卡、失败、偏离各自独立,运营可单独调整某一维度阈值(XML 热更新)而不动其他维度,指标明细与计分维度一一对应、可审计可解释。
- 双层封顶:
scoreVelocity在脚本内对「笔数档 + 累计金额档」做维度内 25 分封顶;Service 再把九维总和 clamp 到 0-100,原始 126 分的高危交易稳定落为 100,75/45 等级阈值永远有效。 - 倍数判定交叉相乘规避浮点误差:
scoreDeviation用amount * 10 >= historicalMaxAmount * 50/30表达 5 倍/3 倍偏离,并先判断历史最大值大于 0 防止新账户误判;Java 侧指标组装沿用同一交叉相乘口径,评分与展示一致。 - 正向 return 与复合条件并用,贴合脚本语法:
scoreGeo以travelMode == false正向比较区分未报备异地,不使用!取反;scoreTime用>= 23 || < 5表达跨午夜时段,needAlert把等级与境外条件组合成独立预警线,与处置动作解耦。 @Param按名绑定、代理可复用、Java 8 可嵌入信创网控:12 个规则方法全部按参数名绑定,含boolean、int、double、str混合类型;代理在@Before创建后六组用例复用,ASM 调用链无独立进程,可嵌入 JDK 8 与国产化支付风控环境。