From 57b271de57e0cc7961abdede7c3240162425abe6 Mon Sep 17 00:00:00 2001 From: daxpay Date: Mon, 3 Aug 2026 11:40:54 +0800 Subject: [PATCH] =?UTF-8?q?test(risk):=20=E6=96=B0=E5=A2=9E=20DefaultPayRi?= =?UTF-8?q?skChecker=20=E5=8D=95=E6=B5=8B,=20=E8=A6=86=E7=9B=96=E6=B5=B7?= =?UTF-8?q?=E5=A4=96IP=E6=8B=A6=E6=88=AA=E4=B8=8E=E9=BB=91=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E5=91=BD=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模块首个测试: pom 加 junit5/mockito 依赖; 28 个用例覆盖海外 IP 网段短路(192.168/10/172.16/127)、 xdb 文本兜底(Reserved/内网IP)、海外阻断, 以及 IP/支付宝/微信黑名单命中与事后补录。 回归保护本次「私网 IP 误判海外」修复。 --- daxpay-plugin/daxpay-plugin-risk/pom.xml | 18 + .../strategy/DefaultPayRiskCheckerTest.java | 457 ++++++++++++++++++ 2 files changed, 475 insertions(+) create mode 100644 daxpay-plugin/daxpay-plugin-risk/src/test/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskCheckerTest.java diff --git a/daxpay-plugin/daxpay-plugin-risk/pom.xml b/daxpay-plugin/daxpay-plugin-risk/pom.xml index 8b2fafc1d..1a6e2f2ab 100644 --- a/daxpay-plugin/daxpay-plugin-risk/pom.xml +++ b/daxpay-plugin/daxpay-plugin-risk/pom.xml @@ -19,5 +19,23 @@ capability-audit-log ${project.version} + + + + org.junit.jupiter + junit-jupiter + test + + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + diff --git a/daxpay-plugin/daxpay-plugin-risk/src/test/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskCheckerTest.java b/daxpay-plugin/daxpay-plugin-risk/src/test/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskCheckerTest.java new file mode 100644 index 000000000..4d477bf83 --- /dev/null +++ b/daxpay-plugin/daxpay-plugin-risk/src/test/java/cn/daxpay/open/plugin/risk/strategy/DefaultPayRiskCheckerTest.java @@ -0,0 +1,457 @@ +package cn.daxpay.open.plugin.risk.strategy; + +import cn.daxpay.open.payment.strategy.risk.PayRiskCheckContext; +import cn.daxpay.open.platform.capability.audit.log.service.ip2region.IpRegion; +import cn.daxpay.open.platform.capability.audit.log.service.ip2region.IpToRegionService; +import cn.daxpay.open.platform.core.enums.pay.channel.ChannelEnum; +import cn.daxpay.open.platform.core.exception.BizInfoException; +import cn.daxpay.open.plugin.risk.entity.PayBlacklist; +import cn.daxpay.open.plugin.risk.enums.PayBlacklistTypeEnum; +import cn.daxpay.open.plugin.risk.service.PayBlacklistService; +import cn.daxpay.open.plugin.risk.service.PayRiskHitService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/// # 默认支付风控检查器测试 +/// +/// 聚焦两条主路径: +/// 1. 海外 IP 地域拦截(网段短路 / xdb 文本兜底 / 海外阻断) — 覆盖本次「私网 IP 误判海外」修复回归 +/// 2. 黑名单命中(IP / 支付宝 userId / 微信 openId) 与事前阻断 / 事后补录 +/// +/// 通过公共 SPI [DefaultPayRiskChecker#checkBeforePay] / [checkAfterPay] 验证, +/// 不直接调用 private 方法, 保证测试的是真实调用链。 +/// +/// ## strictness 说明 +/// `@MockitoSettings(strictness = Strictness.LENIENT)`: `findActive` 在 setUp 中预先 stub 为「未命中」, +/// 但部分用例(空 IP / 未命中 / 网段短路)不会触达, strict 模式会误报未使用 stub, 故放宽。 +/// 各用例仍由精确 verify 断言交互。 +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DefaultPayRiskCheckerTest { + + private static final String IP_TYPE = PayBlacklistTypeEnum.IP.getCode(); + private static final String ALIPAY_USER_TYPE = PayBlacklistTypeEnum.ALIPAY_USER.getCode(); + private static final String WECHAT_OPENID_TYPE = PayBlacklistTypeEnum.WECHAT_OPENID.getCode(); + private static final String OVERSEAS_IP_TYPE = PayBlacklistTypeEnum.OVERSEAS_IP.getCode(); + private static final String BLACKLIST_MSG_KEY = "pay.error.risk.blacklist"; + + @Mock + private PayBlacklistService payBlacklistService; + @Mock + private PayRiskHitService payRiskHitService; + @Mock + private IpToRegionService ipToRegionService; + + private DefaultPayRiskChecker checker; + + @BeforeEach + void setUp() { + checker = new DefaultPayRiskChecker(payBlacklistService, payRiskHitService, ipToRegionService); + // 名单默认不命中, 让流程走到海外检查; 个别用例用 eq() 精确覆写为命中 + lenient().when(payBlacklistService.findActive(anyString(), anyString(), any())) + .thenReturn(Optional.empty()); + } + + // ==================== 海外 IP 拦截 ==================== + + @Test + @DisplayName("海外检查: 空 IP 不查库不记录") + void overseasIp_blankIp_shouldSkipWithoutLookup() { + PayRiskCheckContext ctx = overseasCtx(""); + + assertDoesNotThrow(() -> checker.checkBeforePay(ctx)); + + verify(ipToRegionService, never()).getRegionByIp(anyString()); + verifyNoInteractions(payRiskHitService); + } + + @Test + @DisplayName("海外检查: 私网 192.168 网段直通放行(本次修复回归核心)") + void overseasIp_privateLan192_shouldPassThroughWithoutLookup() { + PayRiskCheckContext ctx = overseasCtx("192.168.1.123"); + + assertDoesNotThrow(() -> checker.checkBeforePay(ctx)); + + // 网段判定直接放行, 根本不查库 + verify(ipToRegionService, never()).getRegionByIp(anyString()); + verifyNoInteractions(payRiskHitService); + } + + @Test + @DisplayName("海外检查: 私网 10/8 网段直通放行") + void overseasIp_privateLan10_shouldPassThroughWithoutLookup() { + assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("10.0.0.5"))); + + verify(ipToRegionService, never()).getRegionByIp(anyString()); + verifyNoInteractions(payRiskHitService); + } + + @Test + @DisplayName("海外检查: 私网 172.16/12 网段直通放行") + void overseasIp_privateLan172_shouldPassThroughWithoutLookup() { + assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("172.16.0.1"))); + + verify(ipToRegionService, never()).getRegionByIp(anyString()); + verifyNoInteractions(payRiskHitService); + } + + @Test + @DisplayName("海外检查: 回环地址直通放行") + void overseasIp_loopback_shouldPassThroughWithoutLookup() { + assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("127.0.0.1"))); + + verify(ipToRegionService, never()).getRegionByIp(anyString()); + verifyNoInteractions(payRiskHitService); + } + + @Test + @DisplayName("海外检查: xdb 查询失败(region=null)放行不记录") + void overseasIp_lookupReturnsNull_shouldPass() { + when(ipToRegionService.getRegionByIp("8.8.8.8")).thenReturn(null); + + assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("8.8.8.8"))); + + verify(payRiskHitService, never()).recordHit(any(), anyString(), anyString(), any()); + } + + @Test + @DisplayName("海外检查: 国内 IP 放行不记录") + void overseasIp_chinaIp_shouldPass() { + when(ipToRegionService.getRegionByIp("114.114.114.114")) + .thenReturn(region("中国", "电信")); + + assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("114.114.114.114"))); + + verify(payRiskHitService, never()).recordHit(any(), anyString(), anyString(), any()); + } + + @Test + @DisplayName("海外检查: 港澳台(country=中国)放行不记录") + void overseasIp_bigChina_shouldPass() { + when(ipToRegionService.getRegionByIp("203.198.1.1")) + .thenReturn(region("中国", "香港").setProvince("香港")); + + assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("203.198.1.1"))); + + verify(payRiskHitService, never()).recordHit(any(), anyString(), anyString(), any()); + } + + @Test + @DisplayName("海外检查: 新版 xdb country=Reserved 文本兜底放行") + void overseasIp_reservedCountryFallback_shouldPass() { + // 169.254 链路本地不在 NetUtil.isInnerIP 覆盖范围, 独立验证 Reserved 文本兜底分支 + when(ipToRegionService.getRegionByIp("169.254.1.1")) + .thenReturn(region("Reserved", "0")); + + assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("169.254.1.1"))); + + verify(payRiskHitService, never()).recordHit(any(), anyString(), anyString(), any()); + } + + @Test + @DisplayName("海外检查: 老版 xdb isp=内网IP 文本兜底放行") + void overseasIp_innerIpLabelFallback_shouldPass() { + when(ipToRegionService.getRegionByIp("169.254.1.1")) + .thenReturn(region("Reserved", "内网IP")); + + assertDoesNotThrow(() -> checker.checkBeforePay(overseasCtx("169.254.1.1"))); + + verify(payRiskHitService, never()).recordHit(any(), anyString(), anyString(), any()); + } + + @Test + @DisplayName("海外检查: 海外 IP 命中阻断下单") + void overseasIp_foreignIp_shouldBlock() { + when(ipToRegionService.getRegionByIp("8.8.8.8")) + .thenReturn(region("美国", "Google")); + + BizInfoException ex = assertThrows(BizInfoException.class, + () -> checker.checkBeforePay(overseasCtx("8.8.8.8"))); + + assertEquals(BLACKLIST_MSG_KEY, ex.getMessageKey()); + } + + @Test + @DisplayName("海外检查: 海外 IP 命中记录参数正确(type=overseas_ip, blacklistId=null)") + void overseasIp_foreignHit_shouldRecordOverseasTypeAndNullBlacklistId() { + when(ipToRegionService.getRegionByIp("8.8.8.8")) + .thenReturn(region("美国", "Google")); + + assertThrows(BizInfoException.class, + () -> checker.checkBeforePay(overseasCtx("8.8.8.8"))); + + ArgumentCaptor typeCap = ArgumentCaptor.forClass(String.class); + ArgumentCaptor valueCap = ArgumentCaptor.forClass(String.class); + verify(payRiskHitService).recordHit(any(), typeCap.capture(), valueCap.capture(), eq(null)); + assertEquals(OVERSEAS_IP_TYPE, typeCap.getValue()); + assertEquals("8.8.8.8", valueCap.getValue()); + } + + @Test + @DisplayName("海外检查: blockOnHit=false 时海外 IP 仅记录不阻断") + void overseasIp_blockOnHitFalse_shouldRecordWithoutThrow() { + when(ipToRegionService.getRegionByIp("8.8.8.8")) + .thenReturn(region("美国", "Google")); + PayRiskCheckContext ctx = overseasCtx("8.8.8.8").setBlockOnHit(false); + + assertDoesNotThrow(() -> checker.checkBeforePay(ctx)); + + verify(payRiskHitService, times(1)).recordHit(any(), eq(OVERSEAS_IP_TYPE), eq("8.8.8.8"), eq(null)); + } + + @Test + @DisplayName("海外检查: checkAfterPay 海外 IP 仅记录不阻断") + void overseasIp_afterPay_shouldRecordWithoutThrow() { + when(ipToRegionService.getRegionByIp("8.8.8.8")) + .thenReturn(region("美国", "Google")); + + assertDoesNotThrow(() -> checker.checkAfterPay(overseasCtx("8.8.8.8"))); + + verify(payRiskHitService, times(1)).recordHit(any(), eq(OVERSEAS_IP_TYPE), eq("8.8.8.8"), eq(null)); + } + + @Test + @DisplayName("海外检查: blockOverseasIp=null 时不触发地域检查") + void overseasIp_switchNull_shouldNotInvokeLookup() { + PayRiskCheckContext ctx = new PayRiskCheckContext() + .setClientIp("8.8.8.8") + .setBlockOverseasIp(null) + .setBlockOnHit(true); + + assertDoesNotThrow(() -> checker.checkBeforePay(ctx)); + + verify(ipToRegionService, never()).getRegionByIp(anyString()); + verify(payRiskHitService, never()).recordHit(any(), anyString(), anyString(), any()); + } + + // ==================== 黑名单 - IP ==================== + + @Test + @DisplayName("IP名单: 命中 + blockOnHit=true 阻断下单") + void ipBlacklist_hitWithBlock_shouldThrow() { + when(payBlacklistService.findActive(eq(IP_TYPE), eq("1.2.3.4"), eq(null))) + .thenReturn(Optional.of(bl(99L))); + + BizInfoException ex = assertThrows(BizInfoException.class, + () -> checker.checkBeforePay(noOverseasCtx().setClientIp("1.2.3.4"))); + + assertEquals(BLACKLIST_MSG_KEY, ex.getMessageKey()); + verify(payRiskHitService).recordHit(any(), eq(IP_TYPE), eq("1.2.3.4"), eq(99L)); + } + + @Test + @DisplayName("IP名单: 命中 + blockOnHit=false 仅记录不阻断") + void ipBlacklist_hitWithoutBlock_shouldRecordOnly() { + when(payBlacklistService.findActive(eq(IP_TYPE), eq("1.2.3.4"), eq(null))) + .thenReturn(Optional.of(bl(99L))); + PayRiskCheckContext ctx = noOverseasCtx().setClientIp("1.2.3.4").setBlockOnHit(false); + + assertDoesNotThrow(() -> checker.checkBeforePay(ctx)); + + verify(payRiskHitService).recordHit(any(), eq(IP_TYPE), eq("1.2.3.4"), eq(99L)); + } + + @Test + @DisplayName("IP名单: 命中 + blockOnHit=null 默认阻断") + void ipBlacklist_hitWithNullBlock_shouldThrowByDefault() { + when(payBlacklistService.findActive(eq(IP_TYPE), eq("1.2.3.4"), eq(null))) + .thenReturn(Optional.of(bl(99L))); + PayRiskCheckContext ctx = noOverseasCtx().setClientIp("1.2.3.4").setBlockOnHit(null); + + assertThrows(BizInfoException.class, () -> checker.checkBeforePay(ctx)); + } + + @Test + @DisplayName("IP名单: clientIp 为空不查名单") + void ipBlacklist_blankIp_shouldNotLookup() { + assertDoesNotThrow(() -> checker.checkBeforePay(noOverseasCtx().setClientIp(""))); + + verify(payBlacklistService, never()).findActive(anyString(), anyString(), any()); + verifyNoInteractions(payRiskHitService); + } + + // ==================== 黑名单 - 用户标识 ==================== + + @Test + @DisplayName("用户标识: 支付宝 openId 命中阻断") + void alipayUser_hit_shouldThrow() { + when(payBlacklistService.findActive(eq(ALIPAY_USER_TYPE), eq("uid-1"), eq(null))) + .thenReturn(Optional.of(bl(88L))); + PayRiskCheckContext ctx = noOverseasCtx() + .setChannel(ChannelEnum.ALIPAY.getCode()) + .setOpenId("uid-1"); + + assertThrows(BizInfoException.class, () -> checker.checkBeforePay(ctx)); + + verify(payRiskHitService).recordHit(any(), eq(ALIPAY_USER_TYPE), eq("uid-1"), eq(88L)); + } + + @Test + @DisplayName("用户标识: 支付宝 openId 未命中放行") + void alipayUser_miss_shouldPass() { + PayRiskCheckContext ctx = noOverseasCtx() + .setChannel(ChannelEnum.ALIPAY.getCode()) + .setOpenId("uid-1"); + + assertDoesNotThrow(() -> checker.checkBeforePay(ctx)); + + verify(payBlacklistService).findActive(eq(ALIPAY_USER_TYPE), eq("uid-1"), eq(null)); + verify(payRiskHitService, never()).recordHit(any(), anyString(), anyString(), any()); + } + + @Test + @DisplayName("用户标识: 微信 openId 命中(带 channelAppId)阻断") + void wechatOpenId_hitWithAppId_shouldThrow() { + when(payBlacklistService.findActive(eq(WECHAT_OPENID_TYPE), eq("wx-oid"), eq("wx-app-123"))) + .thenReturn(Optional.of(bl(77L))); + PayRiskCheckContext ctx = noOverseasCtx() + .setChannel(ChannelEnum.WECHAT.getCode()) + .setOpenId("wx-oid") + .setChannelAppId("wx-app-123"); + + assertThrows(BizInfoException.class, () -> checker.checkBeforePay(ctx)); + + // recordHit 入参不含 wxAppId, 仅 findActive 查询时带 + verify(payRiskHitService).recordHit(any(), eq(WECHAT_OPENID_TYPE), eq("wx-oid"), eq(77L)); + } + + @Test + @DisplayName("用户标识: 微信 openId 未命中放行") + void wechatOpenId_miss_shouldPass() { + PayRiskCheckContext ctx = noOverseasCtx() + .setChannel(ChannelEnum.WECHAT.getCode()) + .setOpenId("wx-oid") + .setChannelAppId("wx-app-123"); + + assertDoesNotThrow(() -> checker.checkBeforePay(ctx)); + + verify(payBlacklistService).findActive(eq(WECHAT_OPENID_TYPE), eq("wx-oid"), eq("wx-app-123")); + verify(payRiskHitService, never()).recordHit(any(), anyString(), anyString(), any()); + } + + @Test + @DisplayName("用户标识: 非支付宝/微信通道不查用户标识名单") + void otherChannel_shouldNotCheckUserIdentity() { + PayRiskCheckContext ctx = noOverseasCtx() + .setChannel("union_pay") + .setClientIp("1.2.3.4") + .setOpenId("some-uid"); + + assertDoesNotThrow(() -> checker.checkBeforePay(ctx)); + + // 仅 IP 名单查询一次, 用户标识名单不查 + verify(payBlacklistService, times(1)).findActive(eq(IP_TYPE), eq("1.2.3.4"), eq(null)); + verify(payBlacklistService, never()).findActive(eq(ALIPAY_USER_TYPE), anyString(), any()); + verify(payBlacklistService, never()).findActive(eq(WECHAT_OPENID_TYPE), anyString(), any()); + } + + // ==================== 事后检查 checkAfterPay ==================== + + @Test + @DisplayName("事后检查: IP 命中仅记录不阻断") + void afterPay_ipHit_shouldRecordOnly() { + when(payBlacklistService.findActive(eq(IP_TYPE), eq("1.2.3.4"), eq(null))) + .thenReturn(Optional.of(bl(99L))); + PayRiskCheckContext ctx = noOverseasCtx().setClientIp("1.2.3.4"); + + assertDoesNotThrow(() -> checker.checkAfterPay(ctx)); + + verify(payRiskHitService).recordHit(any(), eq(IP_TYPE), eq("1.2.3.4"), eq(99L)); + } + + @Test + @DisplayName("事后检查: buyerId≠openId 时补查 buyerId 命中并记录") + void afterPay_buyerIdDiff_shouldSupplementCheck() { + when(payBlacklistService.findActive(eq(ALIPAY_USER_TYPE), eq("uid-b"), eq(null))) + .thenReturn(Optional.of(bl(66L))); + PayRiskCheckContext ctx = noOverseasCtx() + .setChannel(ChannelEnum.ALIPAY.getCode()) + .setOpenId("uid-a") + .setBuyerId("uid-b"); + + assertDoesNotThrow(() -> checker.checkAfterPay(ctx)); + + // openId 查一次(未命中) + buyerId 补查一次(命中), 共两次 + verify(payRiskHitService).recordHit(any(), eq(ALIPAY_USER_TYPE), eq("uid-b"), eq(66L)); + verify(payRiskHitService, never()).recordHit(any(), eq(ALIPAY_USER_TYPE), eq("uid-a"), any()); + } + + // ==================== hasOpenIdBlacklist 缓存 ==================== + + @Test + @DisplayName("缓存: 首次调用查 DB 返回结果") + void hasOpenIdBlacklist_firstCall_shouldQueryDb() { + when(payBlacklistService.hasActiveOpenIdBlacklist()).thenReturn(true); + + assertTrue(checker.hasOpenIdBlacklist()); + + verify(payBlacklistService, times(1)).hasActiveOpenIdBlacklist(); + } + + @Test + @DisplayName("缓存: 30s 内再次调用走缓存, 不重复查 DB") + void hasOpenIdBlacklist_secondCallWithinTtl_shouldHitCache() { + when(payBlacklistService.hasActiveOpenIdBlacklist()).thenReturn(false); + + checker.hasOpenIdBlacklist(); + checker.hasOpenIdBlacklist(); + + // Caffeine 缓存命中, DB 查询仅一次 + verify(payBlacklistService, times(1)).hasActiveOpenIdBlacklist(); + assertFalse(checker.hasOpenIdBlacklist()); + } + + // ==================== 公共夹具 ==================== + + /** 开启海外拦截 + 阻断下单的上下文 */ + private static PayRiskCheckContext overseasCtx(String ip) { + return new PayRiskCheckContext() + .setClientIp(ip) + .setBlockOverseasIp(true) + .setBlockOnHit(true); + } + + /** 关闭海外拦截的上下文(隔离黑名单路径, 避免公网 IP 触发海外检查干扰断言) */ + private static PayRiskCheckContext noOverseasCtx() { + return new PayRiskCheckContext() + .setBlockOverseasIp(false) + .setBlockOnHit(true); + } + + /** 构造只设 id 的名单行(recordHit 仅读取 id) */ + private static PayBlacklist bl(Long id) { + PayBlacklist entity = new PayBlacklist(); + // setId 来自父类 MpBaseEntity, 链式返回父类型, 单独赋值不链式 + entity.setId(id); + return entity; + } + + /** 构造仅含 country/isp 的 IpRegion */ + private static IpRegion region(String country, String isp) { + return new IpRegion().setCountry(country).setIsp(isp); + } +}