diff --git a/.gitignore b/.gitignore index 4388c0f2..44e0668d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ target # Target ant folder build /tmp/ +/.vscode/ diff --git a/pom.xml b/pom.xml index ff03e523..8c5f199f 100644 --- a/pom.xml +++ b/pom.xml @@ -109,6 +109,7 @@ dev.vality damsel + 1.681-7a97267 io.opentelemetry diff --git a/src/main/java/dev/vality/fraudbusters/config/CachingConfig.java b/src/main/java/dev/vality/fraudbusters/config/CachingConfig.java index 1d8ddc87..c7458093 100644 --- a/src/main/java/dev/vality/fraudbusters/config/CachingConfig.java +++ b/src/main/java/dev/vality/fraudbusters/config/CachingConfig.java @@ -1,11 +1,13 @@ package dev.vality.fraudbusters.config; import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import java.util.concurrent.TimeUnit; @@ -13,13 +15,29 @@ @EnableCaching public class CachingConfig { + @Value("${cache.expire-after-access-seconds:100}") + private int expireAfterAccessSeconds; + @Value("${cache.inspect-user-expire-after-access-seconds:300}") + private int inspectUserExpireAfterAccessSeconds; + @Bean + @Primary public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager("resolveCountry", "isNewShop"); cacheManager.setCaffeine(Caffeine.newBuilder() .initialCapacity(200) .maximumSize(500) - .expireAfterAccess(100, TimeUnit.SECONDS)); + .expireAfterAccess(expireAfterAccessSeconds, TimeUnit.SECONDS)); + return cacheManager; + } + + @Bean(name = "inspectUserCacheManager") + public CacheManager inspectUserCacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager("inspectUser"); + cacheManager.setCaffeine(Caffeine.newBuilder() + .initialCapacity(100) + .maximumSize(1000) + .expireAfterAccess(inspectUserExpireAfterAccessSeconds, TimeUnit.SECONDS)); return cacheManager; } } diff --git a/src/main/java/dev/vality/fraudbusters/resource/payment/handler/FraudInspectorHandler.java b/src/main/java/dev/vality/fraudbusters/resource/payment/handler/FraudInspectorHandler.java index e66412b6..fca898fe 100644 --- a/src/main/java/dev/vality/fraudbusters/resource/payment/handler/FraudInspectorHandler.java +++ b/src/main/java/dev/vality/fraudbusters/resource/payment/handler/FraudInspectorHandler.java @@ -2,9 +2,7 @@ import dev.vality.damsel.base.InvalidRequest; import dev.vality.damsel.domain.RiskScore; -import dev.vality.damsel.proxy_inspector.BlackListContext; -import dev.vality.damsel.proxy_inspector.Context; -import dev.vality.damsel.proxy_inspector.InspectorProxySrv; +import dev.vality.damsel.proxy_inspector.*; import dev.vality.damsel.wb_list.*; import dev.vality.fraudbusters.converter.CheckedResultToRiskScoreConverter; import dev.vality.fraudbusters.converter.ContextToFraudRequestConverter; @@ -13,10 +11,20 @@ import dev.vality.fraudbusters.domain.FraudResult; import dev.vality.fraudbusters.fraud.model.PaymentModel; import dev.vality.fraudbusters.stream.TemplateVisitor; +import dev.vality.fraudbusters.util.PaymentModelFactory; +import dev.vality.fraudbusters.util.UserCacheKeyUtil; +import dev.vality.fraudo.constant.ResultStatus; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.thrift.TException; +import org.springframework.cache.annotation.Cacheable; import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.util.CollectionUtils; + +import java.util.AbstractMap; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; @Slf4j @RequiredArgsConstructor @@ -64,4 +72,44 @@ public boolean isBlacklisted(BlackListContext blackListContext) throws InvalidRe } } + @Override + @Cacheable( + cacheManager = "inspectUserCacheManager", + cacheNames = "inspectUser", + key = "#root.target.buildInspectUserCacheKey(#context)" + ) + public BlockedShops inspectUser(InspectUserContext context) throws InvalidRequest, TException { + if (CollectionUtils.isEmpty(context.getShopList())) { + log.warn("FraudInspectorHandler inspectUser with empty shopList: {}", context); + return new BlockedShops().setShopList(Collections.emptyList()); + } + try { + List blockedShops = context.getShopList().stream() + .map(shopContext -> { + PaymentModel paymentModel = PaymentModelFactory.buildPaymentModel(context, shopContext); + CheckedResultModel result = templateVisitor.visit(paymentModel); + return new AbstractMap.SimpleEntry<>(shopContext, result); + }) + .filter(entry -> isDeclineResult(entry.getValue())) + .map(AbstractMap.SimpleEntry::getKey) + .collect(Collectors.toList()); + log.debug("FraudInspectorHandler inspectUser result blockedShops: {}", blockedShops); + return new BlockedShops().setShopList(blockedShops); + } catch (Exception e) { + log.warn("FraudInspectorHandler error when inspectUser e: ", e); + return new BlockedShops().setShopList(Collections.emptyList()); + } + } + + public String buildInspectUserCacheKey(InspectUserContext context) { + return UserCacheKeyUtil.buildInspectUserCacheKey(context); + } + + private static boolean isDeclineResult(CheckedResultModel result) { + return result != null + && result.getResultModel() != null + && (ResultStatus.DECLINE.equals(result.getResultModel().getResultStatus()) + || ResultStatus.DECLINE_AND_NOTIFY.equals(result.getResultModel().getResultStatus())); + } + } diff --git a/src/main/java/dev/vality/fraudbusters/util/PaymentModelFactory.java b/src/main/java/dev/vality/fraudbusters/util/PaymentModelFactory.java new file mode 100644 index 00000000..87c5aafe --- /dev/null +++ b/src/main/java/dev/vality/fraudbusters/util/PaymentModelFactory.java @@ -0,0 +1,34 @@ +package dev.vality.fraudbusters.util; + +import dev.vality.damsel.proxy_inspector.InspectUserContext; +import dev.vality.damsel.proxy_inspector.ShopContext; +import dev.vality.fraudbusters.constant.ClickhouseUtilsValue; +import dev.vality.fraudbusters.fraud.model.PaymentModel; +import org.springframework.util.StringUtils; + +public class PaymentModelFactory { + + public static PaymentModel buildPaymentModel(InspectUserContext context, ShopContext shopContext) { + PaymentModel paymentModel = new PaymentModel(); + paymentModel.setPartyId(shopContext.getParty().getPartyRef().getId()); + paymentModel.setShopId(shopContext.getShop().getShopRef().getId()); + paymentModel.setTimestamp(System.currentTimeMillis()); + if (context.getUserInfo() != null) { + paymentModel.setEmail( + context.getUserInfo().isSetEmail() && StringUtils.hasLength(context.getUserInfo().getEmail()) + ? context.getUserInfo().getEmail().toLowerCase() + : ClickhouseUtilsValue.UNKNOWN); + paymentModel.setPhone( + context.getUserInfo().isSetPhoneNumber() + && StringUtils.hasLength(context.getUserInfo().getPhoneNumber()) + ? context.getUserInfo().getPhoneNumber() + : ClickhouseUtilsValue.UNKNOWN + ); + } else { + paymentModel.setEmail(ClickhouseUtilsValue.UNKNOWN); + paymentModel.setPhone(ClickhouseUtilsValue.UNKNOWN); + } + return paymentModel; + } +} + diff --git a/src/main/java/dev/vality/fraudbusters/util/UserCacheKeyUtil.java b/src/main/java/dev/vality/fraudbusters/util/UserCacheKeyUtil.java new file mode 100644 index 00000000..6a540529 --- /dev/null +++ b/src/main/java/dev/vality/fraudbusters/util/UserCacheKeyUtil.java @@ -0,0 +1,47 @@ +package dev.vality.fraudbusters.util; + +import dev.vality.damsel.proxy_inspector.InspectUserContext; +import dev.vality.damsel.proxy_inspector.ShopContext; +import dev.vality.fraudbusters.constant.ClickhouseUtilsValue; +import org.springframework.util.StringUtils; + +import java.util.Comparator; +import java.util.stream.Collectors; + +public class UserCacheKeyUtil { + + public static String buildInspectUserCacheKey(InspectUserContext context) { + if (context == null) { + return "null"; + } + String email = ClickhouseUtilsValue.UNKNOWN; + String phone = ClickhouseUtilsValue.UNKNOWN; + if (context.getUserInfo() != null) { + email = context.getUserInfo().isSetEmail() && StringUtils.hasLength(context.getUserInfo().getEmail()) + ? context.getUserInfo().getEmail().toLowerCase() + : ClickhouseUtilsValue.UNKNOWN; + phone = context.getUserInfo().isSetPhoneNumber() + && StringUtils.hasLength(context.getUserInfo().getPhoneNumber()) + ? context.getUserInfo().getPhoneNumber() + : ClickhouseUtilsValue.UNKNOWN; + } + if (context.getShopList() == null || context.getShopList().isEmpty()) { + return email + "|" + phone + "|"; + } + String shopsKey = context.getShopList().stream() + .map(UserCacheKeyUtil::buildShopKey) + .sorted(Comparator.naturalOrder()) + .collect(Collectors.joining(",")); + return email + "|" + phone + "|" + shopsKey; + } + + private static String buildShopKey(ShopContext shopContext) { + if (shopContext == null || shopContext.getParty() == null || shopContext.getShop() == null) { + return ClickhouseUtilsValue.UNKNOWN; + } + String partyId = shopContext.getParty().getPartyRef().getId(); + String shopId = shopContext.getShop().getShopRef().getId(); + return partyId + ":" + shopId; + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6a26aa12..7310363b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,8 +35,8 @@ spring: reconnect.backoff.max.ms: 3000 retry.backoff.ms: 1000 ssl: - keystore-location: src/main/resources/cert/kenny-k.struzhkin.p12 - keystore-password: kenny + key-store-location: src/main/resources/cert/kenny-k.struzhkin.p12 + key-store-password: kenny key-password: kenny trust-store-password: kenny12 trust-store-location: src/main/resources/cert/truststore.p12 @@ -46,9 +46,12 @@ spring: ansi: enabled: always cache: - cache-names: resolveCountry + cache-names: resolveCountry,isNewShop,inspectUser caffeine: spec: maximumSize=500,expireAfterAccess=100s +cache: + expire-after-access-seconds: 100 + inspect-user-expire-after-access-seconds: 300 kafka: historical.listener: diff --git a/src/test/java/dev/vality/fraudbusters/resource/payment/handler/FraudInspectorHandlerTest.java b/src/test/java/dev/vality/fraudbusters/resource/payment/handler/FraudInspectorHandlerTest.java index fb45bb04..354be067 100644 --- a/src/test/java/dev/vality/fraudbusters/resource/payment/handler/FraudInspectorHandlerTest.java +++ b/src/test/java/dev/vality/fraudbusters/resource/payment/handler/FraudInspectorHandlerTest.java @@ -1,38 +1,63 @@ package dev.vality.fraudbusters.resource.payment.handler; +import dev.vality.damsel.domain.Category; +import dev.vality.damsel.domain.ContactInfo; +import dev.vality.damsel.domain.PartyConfigRef; +import dev.vality.damsel.domain.ShopConfigRef; import dev.vality.damsel.proxy_inspector.BlackListContext; +import dev.vality.damsel.proxy_inspector.BlockedShops; +import dev.vality.damsel.proxy_inspector.InspectUserContext; +import dev.vality.damsel.proxy_inspector.InspectorProxySrv; +import dev.vality.damsel.proxy_inspector.Party; +import dev.vality.damsel.proxy_inspector.Shop; +import dev.vality.damsel.proxy_inspector.ShopContext; +import dev.vality.damsel.domain.ShopLocation; import dev.vality.damsel.wb_list.ListNotFound; import dev.vality.damsel.wb_list.WbListServiceSrv; import dev.vality.fraudbusters.converter.CheckedResultToRiskScoreConverter; import dev.vality.fraudbusters.converter.ContextToFraudRequestConverter; import dev.vality.fraudbusters.domain.CheckedResultModel; +import dev.vality.fraudbusters.domain.ConcreteResultModel; import dev.vality.fraudbusters.domain.FraudResult; import dev.vality.fraudbusters.fraud.model.PaymentModel; import dev.vality.fraudbusters.stream.TemplateVisitor; +import dev.vality.fraudo.constant.ResultStatus; import org.apache.thrift.TException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.junit.jupiter.SpringExtension; +import java.util.ArrayList; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith({MockitoExtension.class, SpringExtension.class}) class FraudInspectorHandlerTest { - @MockBean + @MockitoBean CheckedResultToRiskScoreConverter checkedResultToRiskScoreConverter; - @MockBean + @MockitoBean ContextToFraudRequestConverter requestConverter; - @MockBean + @MockitoBean TemplateVisitor templateVisitor; - @MockBean + @MockitoBean KafkaTemplate kafkaFraudResultTemplate; - @MockBean + @MockitoBean WbListServiceSrv.Iface wbListServiceSrv; @Test @@ -59,11 +84,75 @@ void isExistInBlackList() throws TException { assertEquals(false, existInBlackList); } - private static BlackListContext createBlackListContext() { + @Test + void inspectUserShopsBlocked() throws TException { + FraudInspectorHandler fraudInspectorHandler = new FraudInspectorHandler( + "test", + checkedResultToRiskScoreConverter, + requestConverter, + templateVisitor, + kafkaFraudResultTemplate, + wbListServiceSrv + ); + + when(templateVisitor.visit(any())).thenAnswer(invocation -> { + PaymentModel model = invocation.getArgument(0); + if (model != null && "shop_1".equals(model.getShopId())) { + return createCheckedResult(ResultStatus.DECLINE); + } + return createCheckedResult(ResultStatus.THREE_DS); + }); + + BlockedShops blockedShops = fraudInspectorHandler.inspectUser(createInspectUserContext()); + + assertEquals(1, blockedShops.getShopListSize()); + assertEquals("shop_1", blockedShops.getShopList().get(0).getShop().getShopRef().getId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PaymentModel.class); + verify(templateVisitor, times(2)).visit(captor.capture()); + for (PaymentModel model : captor.getAllValues()) { + assertEquals("party_1", model.getPartyId()); + assertEquals("user@email.com", model.getEmail()); + assertEquals("79990001122", model.getPhone()); + } + } + + private BlackListContext createBlackListContext() { return new BlackListContext() .setValue("test") .setFieldName("field_test") .setFirstId("test_id") .setSecondId("test_sec_id"); } -} \ No newline at end of file + + private InspectUserContext createInspectUserContext() { + ContactInfo contactInfo = new ContactInfo(); + contactInfo.setEmail("User@Email.Com"); + contactInfo.setPhoneNumber("79990001122"); + return new InspectUserContext() + .setUserInfo(contactInfo) + .setShopList(List.of( + createShopContext("party_1", "shop_1"), + createShopContext("party_1", "shop_2") + )); + } + + private ShopContext createShopContext(String partyId, String shopId) { + ShopLocation location = new ShopLocation(); + location.setUrl("http://example.com"); + return new ShopContext() + .setParty(new Party(new PartyConfigRef(partyId))) + .setShop(new Shop( + new ShopConfigRef(shopId), + new Category("category", "category"), + "shop-name", + location + )); + } + + private CheckedResultModel createCheckedResult(ResultStatus status) { + CheckedResultModel checkedResultModel = new CheckedResultModel(); + checkedResultModel.setResultModel(new ConcreteResultModel(status, null, null)); + return checkedResultModel; + } +}