|
|
@@ -0,0 +1,242 @@
|
|
|
+package com.payment.platform.module.payment.har.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.payment.platform.module.payment.har.client.HarClient;
|
|
|
+import com.payment.platform.module.payment.har.entity.HarShopEntity;
|
|
|
+import com.payment.platform.module.payment.har.enums.HarShopCreateStatus;
|
|
|
+import com.payment.platform.module.payment.har.mapper.HarShopMapper;
|
|
|
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+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.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 按地区挑门店(收款链接下单时用;支付宝对**异地收款**有风控)。
|
|
|
+ *
|
|
|
+ * <p>阶梯只有两级,但每一级的边界都要钉住:同城多店怎么选、同城没店怎么办、
|
|
|
+ * 已失败/未成功建出来的店算不算、以及「挑不出来」必须是**空**而不是随便给一家 ——
|
|
|
+ * 给错地区比拒收更糟(那正是这套东西要规避的风控)。
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class HarShopRegionPickTest {
|
|
|
+
|
|
|
+ @Mock private HarShopMapper shopMapper;
|
|
|
+ @Mock private HarMerchantService merchantService;
|
|
|
+ @Mock private HarClient harClient;
|
|
|
+
|
|
|
+ private HarShopService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog());
|
|
|
+ // 同 HarShopServiceTest:让 LambdaQueryWrapper.getSqlSegment() 能在无 Spring 上下文时工作
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarShopEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static HarShopEntity shop(String thirdShopId, String province, String city, String status, String shopId) {
|
|
|
+ HarShopEntity e = new HarShopEntity();
|
|
|
+ e.setThirdShopId(thirdShopId);
|
|
|
+ e.setMerchantCode("M001");
|
|
|
+ e.setProvinceCode(province);
|
|
|
+ e.setCityCode(city);
|
|
|
+ e.setCreateStatus(status);
|
|
|
+ e.setShopId(shopId);
|
|
|
+ return e;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static HarShopEntity ok(String thirdShopId, String province, String city) {
|
|
|
+ return shop(thirdShopId, province, city, HarShopCreateStatus.SUCCESS, "S-" + thirdShopId);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static HarShopEntity pick(List<HarShopEntity> candidates, String province, String city, String seed) {
|
|
|
+ return HarShopService.pickFromCandidates(candidates, province, city, seed);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ──────────────────────────── 两级阶梯 ────────────────────────────
|
|
|
+
|
|
|
+ /** 同城有店 ⇒ 就挑同城的,哪怕省内别处还有别的店 */
|
|
|
+ @Test
|
|
|
+ void pick_cityTierWins() {
|
|
|
+ List<HarShopEntity> candidates = List.of(
|
|
|
+ ok("NJ", "32", "3201"),
|
|
|
+ ok("SZ", "32", "3205"));
|
|
|
+
|
|
|
+ assertEquals("NJ", pick(candidates, "32", "3201", "114.114.114.114").getThirdShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 同城没店 ⇒ 退到同省,且**只**在省内挑(异省的店绝不越界) */
|
|
|
+ @Test
|
|
|
+ void pick_cityMissing_fallsBackToTheSameProvinceOnly() {
|
|
|
+ List<HarShopEntity> candidates = List.of(
|
|
|
+ ok("SZ", "32", "3205"),
|
|
|
+ ok("WX", "32", "3202"),
|
|
|
+ ok("HZ", "33", "3301"));
|
|
|
+
|
|
|
+ HarShopEntity picked = pick(candidates, "32", "3201", "114.114.114.114");
|
|
|
+
|
|
|
+ assertNotNull(picked);
|
|
|
+ assertTrue(Set.of("SZ", "WX").contains(picked.getThirdShopId()),
|
|
|
+ "越省挑店正是要规避的风控,实际: " + picked.getThirdShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 省市编码是**一对**:城市码虽是全国唯一,但一行数据若省码对不上(人工改错、跨库搬数据),
|
|
|
+ * 就不该被信任 —— 宁可不挑(下游拒收),也不要按一个自相矛盾的地址把顾客导去别的省。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void pick_cityCodeUnderAnotherProvinceIsNotTrusted() {
|
|
|
+ List<HarShopEntity> candidates = List.of(ok("X", "99", "3201"));
|
|
|
+
|
|
|
+ assertNull(pick(candidates, "32", "3201", "114.114.114.114"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void pick_blankProvinceCode_picksNothing() {
|
|
|
+ List<HarShopEntity> candidates = List.of(ok("NJ", "32", "3201"));
|
|
|
+
|
|
|
+ assertNull(pick(candidates, null, "3201", "114.114.114.114"));
|
|
|
+ assertNull(pick(candidates, " ", "3201", "114.114.114.114"));
|
|
|
+ // 正控:同一个候选集,省码给对就必须挑得出来(否则上两条是量具坏了在假绿)
|
|
|
+ assertNotNull(pick(candidates, "32", "3201", "114.114.114.114"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 空 city 只走省级(ipwho.is 偶尔只给到省) */
|
|
|
+ @Test
|
|
|
+ void pick_blankCityCode_stillUsesTheProvinceTier() {
|
|
|
+ List<HarShopEntity> candidates = List.of(ok("NJ", "32", "3201"));
|
|
|
+
|
|
|
+ assertEquals("NJ", pick(candidates, "32", null, "114.114.114.114").getThirdShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ──────────────────────────── 什么算「能用的店」 ────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 只有 {@code SUCCESS} 且拿到了 {@code shop_id} 的店才算建成 —— PENDING 是「结果未知」,
|
|
|
+ * FAILED 是上游明确拒绝,两者拿去下单必然失败。末段是**正控**:把一家好店放进同一个候选集,
|
|
|
+ * 必须能挑到它,否则上面的三个 null 可能只是「这方法根本不挑任何东西」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void pick_onlyConsidersSuccessfullyCreatedShops() {
|
|
|
+ List<HarShopEntity> unusable = List.of(
|
|
|
+ shop("FAILED", "32", "3201", HarShopCreateStatus.FAILED, "S1"),
|
|
|
+ shop("PENDING", "32", "3201", HarShopCreateStatus.PENDING, "S2"),
|
|
|
+ shop("NOID", "32", "3201", HarShopCreateStatus.SUCCESS, null),
|
|
|
+ shop("BLANKID", "32", "3201", HarShopCreateStatus.SUCCESS, " "),
|
|
|
+ shop("NOSTATUS", "32", "3201", null, "S5"));
|
|
|
+ assertNull(pick(unusable, "32", "3201", "114.114.114.114"), "未建成的店不能拿去下单");
|
|
|
+
|
|
|
+ List<HarShopEntity> withOneGood = new ArrayList<>(unusable);
|
|
|
+ withOneGood.add(ok("GOOD", "32", "3201"));
|
|
|
+ assertEquals("GOOD", pick(withOneGood, "32", "3201", "114.114.114.114").getThirdShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void pick_noCandidatesAtAll_picksNothing() {
|
|
|
+ assertNull(pick(List.of(), "32", "3201", "114.114.114.114"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ──────────────────────────── 同地区多店:按 IP 稳定散列 ────────────────────────────
|
|
|
+
|
|
|
+ private static List<HarShopEntity> fourShopsInJiangsu() {
|
|
|
+ return List.of(
|
|
|
+ ok("A", "32", "3201"),
|
|
|
+ ok("B", "32", "3201"),
|
|
|
+ ok("C", "32", "3201"),
|
|
|
+ ok("D", "32", "3201"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 同一个顾客(同 IP)在缓存过期后重来一次,必须还是那一家 */
|
|
|
+ @Test
|
|
|
+ void pick_sameSeedIsStable() {
|
|
|
+ List<HarShopEntity> candidates = fourShopsInJiangsu();
|
|
|
+
|
|
|
+ for (int i = 0; i < 50; i++) {
|
|
|
+ String seed = "10.0." + (i / 250) + "." + (i % 250);
|
|
|
+ assertEquals(pick(candidates, "32", "3201", seed).getThirdShopId(),
|
|
|
+ pick(candidates, "32", "3201", seed).getThirdShopId(),
|
|
|
+ "同一 IP 必须稳定落到同一家,seed=" + seed);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 但不同 IP 要真的散开 —— 全落在一家就成了单点(这也是「按 IP 散列」而不是「固定第一家」的意义) */
|
|
|
+ @Test
|
|
|
+ void pick_distinctSeedsSpreadAcrossShops() {
|
|
|
+ List<HarShopEntity> candidates = fourShopsInJiangsu();
|
|
|
+ Set<String> chosen = new HashSet<>();
|
|
|
+
|
|
|
+ for (int i = 0; i < 400; i++) {
|
|
|
+ chosen.add(pick(candidates, "32", "3201", "10.0." + (i / 250) + "." + (i % 250)).getThirdShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(Set.of("A", "B", "C", "D"), chosen, "400 个不同 IP 应把所有店都用上");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** seed 为空也要是个**总函数**:不抛,且稳定(正常路径不会走到这里,但别让它炸在顾客页上) */
|
|
|
+ @Test
|
|
|
+ void pick_blankSeedIsStillDeterministic() {
|
|
|
+ List<HarShopEntity> candidates = fourShopsInJiangsu();
|
|
|
+
|
|
|
+ HarShopEntity first = pick(candidates, "32", "3201", null);
|
|
|
+ assertNotNull(first);
|
|
|
+ assertEquals(first.getThirdShopId(), pick(candidates, "32", "3201", null).getThirdShopId());
|
|
|
+ assertEquals(first.getThirdShopId(), pick(candidates, "32", "3201", " ").getThirdShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ──────────────────────────── 查询本身 ────────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询必须**限定在这个商户下**:门店是挂在上游商户下的,跨商户的 shop_id 拿去下单必被拒。
|
|
|
+ * 这条钉住 SQL 谓词 —— 阶梯是纯函数、好测,真正容易被写漏的反而是这句 WHERE。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void pickForRegion_scopesTheQueryToTheMerchant() {
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of());
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<HarShopEntity>> captor =
|
|
|
+ ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+
|
|
|
+ assertNull(service.pickForRegion("M001", "32", "3201", "114.114.114.114"));
|
|
|
+
|
|
|
+ verify(shopMapper).selectList(captor.capture());
|
|
|
+ String sql = captor.getValue().getSqlSegment();
|
|
|
+ assertTrue(sql.contains("merchant_code"), "实际: " + sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 商户号本身为空就别去查库了(空串会变成 merchant_code = '',查出一片空并静默拒收) */
|
|
|
+ @Test
|
|
|
+ void pickForRegion_blankMerchantCode_picksNothingWithoutQuerying() {
|
|
|
+ assertNull(service.pickForRegion(" ", "32", "3201", "114.114.114.114"));
|
|
|
+
|
|
|
+ verify(shopMapper, org.mockito.Mockito.never()).selectList(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 查出来的东西交给阶梯 —— 这条把两半接起来(上面各测一半,接错也看不出来) */
|
|
|
+ @Test
|
|
|
+ void pickForRegion_picksFromWhatTheQueryReturned() {
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of(
|
|
|
+ ok("NJ", "32", "3201"),
|
|
|
+ ok("HZ", "33", "3301")));
|
|
|
+
|
|
|
+ assertEquals("NJ", service.pickForRegion("M001", "32", "3201", "114.114.114.114").getThirdShopId());
|
|
|
+ }
|
|
|
+}
|