Case 15: Bank card transaction anti-fraud
This case corresponds to Scenario 15 in the JQuick-Java repository’s USE-CASES.md, targeting bank card payment anti-fraud in the real-time risk-control domain. For each transaction snapshot, the system scores nine risk dimensions independently — amount, short-term frequency, geographic location, transaction time slot, device, merchant, card-not-present, authentication failures, and behavior deviation / new account — then aggregates them into a risk score that decides interception (BLOCK), step-up verification (STEP_UP, SMS/face), or pass (PASS). Trusted-merchant whitelist transactions short-circuit before scoring; medium-risk overseas transactions and high-risk transactions trigger real-time alerts. All scoring and routing criteria are driven by JQuick-Java XML rule proxies.
Industry scenario
Section titled “Industry scenario”Bank card payment links require millisecond-level risk-control decisions. An authorization request carries the transaction serial number, masked card number, amount, transaction hour (0-23), channel (POS / ATM / ONLINE / MOBILE), whether it is overseas / cross-city / reported travel, the transaction count and accumulated amount in the last hour, whether the merchant is new, whether the device changed, whether the card is present, authentication failure count in the last 24 hours, account age in days, historical maximum single amount, and whether the merchant is trusted (whitelisted).
The decision entry point is the whitelist short-circuit: transactions with a trusted merchant (trustedMerchant) directly get 0 points, LOW, PASS, with only one indicator “trusted merchant whitelist transaction, passed directly” — the nine-dimension scoring is not executed at all, protecting the success rate and response latency of trusted payees such as utility payments and large platforms.
Non-whitelisted transactions enter the nine-dimension independent scoring: amount gives 30/20/12/6 for ≥50k/20k/10k/5k; the frequency dimension first gives 25/15/8 for ≥10/≥5/≥3 transactions in 1 hour, then adds +15/+8 for short-term accumulated amounts ≥100k/50k, capped at 25 within the dimension; geographic location gives 25 for overseas, 15 for cross-city unreported, and only 5 for reported travel; time slot gives 12 for 23:00-05:00 and 6 for 22:00 and 06:00; device change gives 15; new merchant gives 8; online/mobile card-not-present gives 10; 24-hour authentication failures give 10 for ≥3 and 5 for ≥1; the behavior-deviation dimension adds 15/8 when the amount reaches 5x/3x of the historical maximum, plus another +15 for accounts younger than 7 days.
After the nine dimensions are aggregated, the Service clamps the result to 0-100 and maps it to risk level and action: ≥75 is HIGH and blocked, ≥45 is MEDIUM with step-up verification (SMS/face), and the rest are LOW and passed. Alerting runs in parallel with the action: HIGH always alerts, and MEDIUM with an overseas transaction also alerts. The decision result also carries risk indicator details assembled item by item from transaction elements (overseas transaction, late-night slot, device change, card-not-present, amount deviating from history, etc.) for risk-control back-office review and customer-service explanation.
Business pain points
Section titled “Business pain points”- Many risk factors with fast-changing criteria: each of the nine dimensions has its own amount tiers, count tiers, and time-slot boundaries; risk operations tune thresholds frequently, and every tuning hard-coded in the authorization service requires a core-system release.
- The whitelist must execute first with zero collateral damage: trusted-merchant transactions should pass even at night and card-not-present; if the whitelist check is mixed in after scoring, stacked high scores cause false interceptions that hurt key-customer success rates.
- Multi-dimensional score stacking easily exceeds the theoretical ceiling: the sum of the nine dimension maximums exceeds 100; the late-night overseas large-amount case has a raw score of 126, and without clamping on the Java side, illegal scores like 126 appear and the level thresholds lose meaning.
- Multiple comparisons carry floating-point error risks: comparing amount deviation from history with floating-point
amount / historicalMax >= 5.0can jitter across tiers at boundary values. - The frequency dimension is composite scoring with an internal cap: the count tier and the short-term accumulated-amount tier must be summed, but the dimension contribution must not exceed 25 — this “tier stacking + dimension cap” pattern is hard to express with plain configuration tables.
- Alerting and action are two rule lines and must be auditable: medium-risk overseas transactions alert, but medium-risk domestic ones do not; every interception/step-up must list the hit risk indicators. Many bank cores and payment gateways still run on JDK 8 / IT-innovation environments and cannot rely on heavy real-time rule platforms.
Solution architecture
Section titled “Solution architecture”This case adopts the six-piece structure of “input model + decision result model + rule interface + XML rule script + Service orchestration + JUnit tests”. Nine-dimension scoring, level thresholds, actions, and alert criteria are all externalized in XML; the Service handles the whitelist short-circuit, total-score clamp, and risk-indicator assembly:
| Layer | Responsibility | Files |
|---|---|---|
| Domain model | Transaction snapshot, risk decision (risk score / level / action / alert / indicator details) | BankTransaction.java, FraudDecision.java |
| Rule set & XML | Nine risk factors + level / action / alert (12 methods) | TransactionFraudRules.java, transaction-fraud.xml |
| Orchestration service | Whitelist short-circuit, nine-dimension aggregation with 0-100 clamp, risk-indicator assembly | TransactionFraudService.java |
| Tests | 6 transaction profiles (routine, high-risk, reported travel, step-up, new account, whitelist) | TransactionFraudServiceTest.java |
Rule method breakdown
Section titled “Rule method breakdown”The rule interface has 12 methods in total: nine risk factors each score independently, and level, action, and alert route in sequence:
| Rule method | Criterion | Score / output |
|---|---|---|
scoreAmount |
Amount ≥50000 gives 30 / ≥20000 gives 20 / ≥10000 gives 12 / ≥5000 gives 6 / otherwise 0 | 0 ~ 30 |
scoreVelocity |
1-hour count ≥10/≥5/≥3 gives 25/15/8; accumulated amount ≥100k +15, ≥50k +8; capped at 25 within the dimension | 0 ~ 25 |
scoreGeo |
Overseas 25; cross-city and unreported (travelMode == false) 15; cross-city reported 5; same city 0 | 0 ~ 25 |
scoreTime |
Hour ≥23 or <5 gives 12; ≥22 or ≤6 gives 6; otherwise 0 |
0~12 |
scoreDevice |
Device changed gives 15, otherwise 0 | 0 / 15 |
scoreMerchant |
First transaction with a new merchant gives 8, otherwise 0 | 0 / 8 |
scoreCardNotPresent |
Card-not-present on ONLINE or MOBILE channel gives 10, otherwise 0 | 0 / 10 |
scoreFailure |
24h authentication failures ≥3 gives 10 / ≥1 gives 5 / otherwise 0 | 0 ~ 10 |
scoreDeviation |
When historical max >0, amount at 5x gives 15, at 3x gives 8 (cross-multiplication comparison); account age <7 days adds +15 |
0~30 |
riskLevel |
≥75 HIGH / ≥45 MEDIUM / otherwise LOW | Level string |
fraudAction |
HIGH→BLOCK; MEDIUM→STEP_UP; LOW→PASS | Action string |
needAlert |
HIGH always alerts; MEDIUM with overseas transaction alerts; otherwise no alert | boolean |
Code implementation (complete & runnable)
Section titled “Code implementation (complete & runnable)”Place the following six files into the JQuick-Java test project using the original package names from the source: the two models go into com.github.paohaijiao.demo.model, the rule interface and test class go into com.github.paohaijiao.demo, the orchestration service goes into com.github.paohaijiao.demo.service, and the XML script goes into src/test/resources/demo/. Keeping directories and package names unchanged, run mvn test -Dtest=TransactionFraudServiceTest at the project root.
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; }}Run & verified results
Section titled “Run & verified results”Run at the project root:
mvn test -Dtest=TransactionFraudServiceTestTest coverage
Section titled “Test coverage”| Test method | Constructed business profile | Key assertions / expected decision |
|---|---|---|
shouldPassNormalTransaction |
POS card-present 200 yuan at 14:00, old account, no risk factors (TXN-1501) | 0 points, LOW, PASS, no alert, empty indicator list |
shouldBlockHighRiskOverseasNightTransaction |
Overseas online 30k at 02:00, new device, new merchant, card-not-present, 1 failure, deviates from history (TXN-1502) | Raw 126 clamped to 100, HIGH, BLOCK, alert, with overseas / late-night / device / card-not-present / deviation indicators |
shouldPassReportedTravelModeTransaction |
Cross-city reported at 20:00, MOBILE card-not-present 6000, new merchant (TXN-1503) | 29 points (6+5+8+10), LOW, PASS, no alert, with reported-travel and MOBILE card-not-present indicators |
shouldStepUpMediumRiskNightTransaction |
Online 12k at 23:00, new device, new merchant, 4 transactions in 1 hour (TXN-1504) | 65 points, MEDIUM, STEP_UP, no alert for domestic, with 4-transactions / late-night indicators |
shouldStepUpNewAccountWithAuthFailures |
Account age 3 days, 4 failures in 24h, 6000 yuan deviating from 1000 historical max, new merchant card-not-present (TXN-1505) | 72 points, MEDIUM, STEP_UP, with new-account-3-days and 4-auth-failures indicators |
shouldBypassForTrustedMerchant |
Overseas online 40k at 01:00, 8 transactions / 80k in 1 hour, 3 failures and more, but merchant is trusted (TXN-1506) | Whitelist short-circuit: 0 points, LOW, PASS, no alert, only 1 whitelist indicator |
Verified output
Section titled “Verified output”Late-night overseas online large amount (02:00, 30k, overseas, new device, card-not-present, 1 failure — nine factors stacked):
风险分: 100 (HIGH) 处置: BLOCK 实时预警: true指标: 境外交易 | 深夜时段 | 设备变更 | 无卡交易 | 金额偏离历史……Trusted merchant whitelist (multiple risk factors hit but merchant is trusted):
风险分: 0 (LOW) 处置: PASS 指标: 受信商户白名单交易,直接放行Technical highlights
Section titled “Technical highlights”- The whitelist short-circuits first on the Java side: the first branch of
evaluateistrustedMerchant; a hit writes 0/LOW/PASS and returns immediately, calling none of the nine scoring methods — ensuring zero collateral damage for trusted merchants and saving rule-invocation overhead, fitting millisecond-level authorization links. - One method per scoring dimension, independently tunable: amount, frequency, geo, time slot, device, merchant, card-not-present, failure, and deviation are all independent; operations can tune one dimension’s thresholds (XML hot update) without touching the others, and the indicator details correspond one-to-one with scoring dimensions — auditable and explainable.
- Two-layer capping:
scoreVelocitycaps the “count tier + accumulated-amount tier” at 25 within the script; the Service then clamps the nine-dimension total to 0-100, so the raw 126-point high-risk transaction lands stably at 100 and the 75/45 level thresholds stay valid forever. - Multiple judgments use cross-multiplication to avoid floating-point error:
scoreDeviationexpresses 5x/3x deviation withamount * 10 >= historicalMaxAmount * 50/30, and first checks the historical maximum is greater than 0 to prevent misjudging new accounts; the Java-side indicator assembly uses the same cross-multiplication criterion, keeping scoring and display consistent. - Positive returns combined with compound conditions, fitting script syntax:
scoreGeouses the positive comparisontravelMode == falseto distinguish unreported cross-city travel without!negation;scoreTimeexpresses the cross-midnight slot with>= 23 || < 5;needAlertcombines level and overseas conditions into an independent alert line, decoupled from the action. @Paramname-based binding, reusable proxies, Java 8 embeddable into IT-innovation gateways: all 12 rule methods bind by parameter name, with mixedboolean,int,double, andstrtypes; the proxy created in@Beforeis reused across six test cases, and the ASM call chain has no standalone process, embedding into JDK 8 and domestic payment risk-control environments.