|
@@ -20,6 +20,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
import java.util.ArrayList;
|
|
import java.util.ArrayList;
|
|
|
import java.util.HashSet;
|
|
import java.util.HashSet;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
|
|
+import java.util.Optional;
|
|
|
import java.util.Set;
|
|
import java.util.Set;
|
|
|
|
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
@@ -44,12 +45,13 @@ class HarShopRegionPickTest {
|
|
|
@Mock private HarShopMapper shopMapper;
|
|
@Mock private HarShopMapper shopMapper;
|
|
|
@Mock private HarMerchantService merchantService;
|
|
@Mock private HarMerchantService merchantService;
|
|
|
@Mock private HarClient harClient;
|
|
@Mock private HarClient harClient;
|
|
|
|
|
+ @Mock private HarIpRegionResolver ipRegionResolver;
|
|
|
|
|
|
|
|
private HarShopService service;
|
|
private HarShopService service;
|
|
|
|
|
|
|
|
@BeforeEach
|
|
@BeforeEach
|
|
|
void setUp() {
|
|
void setUp() {
|
|
|
- service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog());
|
|
|
|
|
|
|
+ service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog(), ipRegionResolver);
|
|
|
// 同 HarShopServiceTest:让 LambdaQueryWrapper.getSqlSegment() 能在无 Spring 上下文时工作
|
|
// 同 HarShopServiceTest:让 LambdaQueryWrapper.getSqlSegment() 能在无 Spring 上下文时工作
|
|
|
MybatisConfiguration configuration = new MybatisConfiguration();
|
|
MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarShopEntity.class);
|
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarShopEntity.class);
|
|
@@ -242,88 +244,178 @@ class HarShopRegionPickTest {
|
|
|
assertEquals("NJ", service.pickForRegion("M001", "32", "3201", "114.114.114.114").getThirdShopId());
|
|
assertEquals("NJ", service.pickForRegion("M001", "32", "3201", "114.114.114.114").getThirdShopId());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ──────────────────────────── 下单按调用方直传的地区码挑店(开放 API,2026-10-09) ────────────────────────────
|
|
|
|
|
|
|
+ // ──────────────── 下单按顾客归属挑店(开放 API,2026-10-09 改版:IP 优先、省码回退) ────────────────
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 与收款链接腿的差别只在**地区来源**:那边是顾客 IP 解析出的地名,这边是调用方
|
|
|
|
|
- * 直接给的 GB/T 2260 码。阶梯本身上面各条已钉住,这里钉「码怎么校验、挑不出来怎么办」。
|
|
|
|
|
|
|
+ * 与收款链接腿的差别只在**地区来源**:那边永远是顾客请求 IP 解析出的地名,这边是
|
|
|
|
|
+ * 调用方给的**顾客 IP 或 GB/T 2260 省码**。阶梯本身上面各条已钉住,这里钉
|
|
|
|
|
+ * 「来源怎么定优先级、挑不出来怎么办」。
|
|
|
*/
|
|
*/
|
|
|
|
|
+ private static HarIpRegionResolver.IpGeo ipGeoHunan(String cityName) {
|
|
|
|
|
+ // 与真实回包同形:省带「省」、市不带「市」
|
|
|
|
|
+ return new HarIpRegionResolver.IpGeo("CN", "湖南省", "HN", cityName);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** IP 能解析出归属地 ⇒ 以它为准,哪怕同时传的 province_code 指向别处(IP 更精确) */
|
|
|
@Test
|
|
@Test
|
|
|
- void pickForOrder_provinceOnly_stillPicksWithinProvince() {
|
|
|
|
|
|
|
+ void pickForOrder_ipResolves_beatsTheProvinceCode() {
|
|
|
|
|
+ when(ipRegionResolver.lookup("114.114.114.114")).thenReturn(Optional.of(ipGeoHunan("长沙")));
|
|
|
when(shopMapper.selectList(any())).thenReturn(List.of(
|
|
when(shopMapper.selectList(any())).thenReturn(List.of(
|
|
|
- ok("SZ", "32", "3205"),
|
|
|
|
|
- ok("HZ", "33", "3301")));
|
|
|
|
|
|
|
+ ok("NJ", "32", "3201"),
|
|
|
|
|
+ ok("CS", "43", "4301")));
|
|
|
|
|
|
|
|
- HarShopEntity picked = service.pickForOrder("M001", "32", null, "BIZ-1");
|
|
|
|
|
|
|
+ assertEquals("CS", service.pickForOrder("M001", "32", "114.114.114.114", "BIZ-1").getThirdShopId(),
|
|
|
|
|
+ "省码是江苏、IP 在湖南 ⇒ 挑湖南的店");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** IP 解析到了省、市名对不上目录(上游常回区级名)⇒ 退到 IP 所在省挑,不报错也不改用省码 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void pickForOrder_ipCityUnknown_fallsBackToTheIpProvince() {
|
|
|
|
|
+ when(ipRegionResolver.lookup("114.114.114.114")).thenReturn(Optional.of(ipGeoHunan("渝中區")));
|
|
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of(
|
|
|
|
|
+ ok("CS", "43", "4301"),
|
|
|
|
|
+ ok("NJ", "32", "3201")));
|
|
|
|
|
|
|
|
- assertEquals("SZ", picked.getThirdShopId(), "不给市码 ⇒ 按全省挑,异省的店绝不越界");
|
|
|
|
|
|
|
+ assertEquals("CS", service.pickForOrder("M001", "32", "114.114.114.114", "BIZ-1").getThirdShopId(),
|
|
|
|
|
+ "IP 的省里有店就不该回退到调用方传的省码");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** IP 归属地一家可用门店都没有 ⇒ 回退 province_code 继续挑(2026-10-09 用户裁定) */
|
|
|
@Test
|
|
@Test
|
|
|
- void pickForOrder_cityCode_reachesTheCityTier() {
|
|
|
|
|
|
|
+ void pickForOrder_ipRegionHasNoShop_fallsBackToProvinceCode() {
|
|
|
|
|
+ when(ipRegionResolver.lookup("114.114.114.114")).thenReturn(Optional.of(ipGeoHunan("长沙")));
|
|
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of(ok("NJ", "32", "3201")));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("NJ", service.pickForOrder("M001", "32", "114.114.114.114", "BIZ-1").getThirdShopId());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 挑中的店没有 third_shop_id(脏数据)与「没挑到」同权 ⇒ 同样回退省码 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void pickForOrder_ipPickYieldsShopWithoutThirdShopId_fallsBackToProvinceCode() {
|
|
|
|
|
+ when(ipRegionResolver.lookup("114.114.114.114")).thenReturn(Optional.of(ipGeoHunan("长沙")));
|
|
|
when(shopMapper.selectList(any())).thenReturn(List.of(
|
|
when(shopMapper.selectList(any())).thenReturn(List.of(
|
|
|
- ok("NJ", "32", "3201"),
|
|
|
|
|
- ok("SZ", "32", "3205")));
|
|
|
|
|
|
|
+ shop(null, "43", "4301", HarShopCreateStatus.SUCCESS, "S-PLATFORM"),
|
|
|
|
|
+ ok("NJ", "32", "3201")));
|
|
|
|
|
|
|
|
- assertEquals("NJ", service.pickForOrder("M001", "32", "3201", "BIZ-1").getThirdShopId());
|
|
|
|
|
|
|
+ assertEquals("NJ", service.pickForOrder("M001", "32", "114.114.114.114", "BIZ-1").getThirdShopId());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** 给了市码但该市没有可用门店 ⇒ 回退全省挑(与收款链接腿同一步梯),不是报错 */
|
|
|
|
|
|
|
+ /** IP 解析不出(内网/非大陆/取数失败)⇒ 有省码就按省挑 —— 这正是「两个可以同时传」的用法 */
|
|
|
@Test
|
|
@Test
|
|
|
- void pickForOrder_cityMissing_fallsBackToTheSameProvince() {
|
|
|
|
|
- when(shopMapper.selectList(any())).thenReturn(List.of(ok("SZ", "32", "3205")));
|
|
|
|
|
|
|
+ void pickForOrder_ipUnresolvable_fallsBackToProvinceCode() {
|
|
|
|
|
+ when(ipRegionResolver.lookup("1.2.3.4")).thenReturn(Optional.empty());
|
|
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of(ok("NJ", "32", "3201")));
|
|
|
|
|
|
|
|
- assertEquals("SZ", service.pickForOrder("M001", "32", "3201", "BIZ-1").getThirdShopId());
|
|
|
|
|
|
|
+ assertEquals("NJ", service.pickForOrder("M001", "32", "1.2.3.4", "BIZ-1").getThirdShopId());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 解析出了但省名不在目录里 ⇒ 同样回退省码(港澳台已被解析器在更上游挡掉) */
|
|
|
@Test
|
|
@Test
|
|
|
- void pickForOrder_unknownProvinceCode_400_sameMessageAsShopCreate() {
|
|
|
|
|
|
|
+ void pickForOrder_ipRegionNameNotInCatalog_fallsBackToProvinceCode() {
|
|
|
|
|
+ when(ipRegionResolver.lookup("1.2.3.4"))
|
|
|
|
|
+ .thenReturn(Optional.of(new HarIpRegionResolver.IpGeo("CN", "某某省", "XX", "某市")));
|
|
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of(ok("NJ", "32", "3201")));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("NJ", service.pickForOrder("M001", "32", "1.2.3.4", "BIZ-1").getThirdShopId());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 只传 ip 且解析不出:是调用方的输入错误,响亮拒绝 —— 静默不带门店下单 = 地区意图悄悄落空 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void pickForOrder_ipOnly_unresolvable_400() {
|
|
|
|
|
+ when(ipRegionResolver.lookup("1.2.3.4")).thenReturn(Optional.empty());
|
|
|
|
|
+
|
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
|
- () -> service.pickForOrder("M001", "99", null, "BIZ-1"));
|
|
|
|
|
|
|
+ () -> service.pickForOrder("M001", null, "1.2.3.4", "BIZ-1"));
|
|
|
|
|
|
|
|
assertEquals(400, e.getCode());
|
|
assertEquals(400, e.getCode());
|
|
|
- assertEquals("省份编码不存在: 99", e.getMessage(),
|
|
|
|
|
- "与门店创建那条必须一字不差 —— 开放文档 10.1/10.2 引用的是同一句话");
|
|
|
|
|
|
|
+ assertEquals("用户 IP 无法解析出归属地: 1.2.3.4", e.getMessage());
|
|
|
verify(shopMapper, org.mockito.Mockito.never()).selectList(any());
|
|
verify(shopMapper, org.mockito.Mockito.never()).selectList(any());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 只传 ip、解析出了地区但一家店都没有:报的是**那个地区**,不是「解析不出」 */
|
|
|
@Test
|
|
@Test
|
|
|
- void pickForOrder_cityNotInThatProvince_400_sameMessageAsShopCreate() {
|
|
|
|
|
|
|
+ void pickForOrder_ipOnly_regionHasNoShop_400_namesTheIpRegion() {
|
|
|
|
|
+ when(ipRegionResolver.lookup("114.114.114.114")).thenReturn(Optional.of(ipGeoHunan("长沙")));
|
|
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of(ok("NJ", "32", "3201")));
|
|
|
|
|
+
|
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
|
- () -> service.pickForOrder("M001", "32", "3301", "BIZ-1"));
|
|
|
|
|
|
|
+ () -> service.pickForOrder("M001", null, "114.114.114.114", "BIZ-1"));
|
|
|
|
|
|
|
|
- assertEquals(400, e.getCode());
|
|
|
|
|
- assertEquals("城市编码不属于该省: 3301(省 32)", e.getMessage());
|
|
|
|
|
|
|
+ assertEquals("该地区没有可用门店: 4301(省 43)", e.getMessage());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** 只给了市码没给省码:是调用方的输入错误,响亮拒绝(省码是地区定位的根) */
|
|
|
|
|
|
|
+ /** 两个都没传(上线路径由 create 的守卫挡住,这里防将来直调时抛 NPE) */
|
|
|
@Test
|
|
@Test
|
|
|
- void pickForOrder_cityWithoutProvince_400() {
|
|
|
|
|
|
|
+ void pickForOrder_neitherIpNorProvince_400() {
|
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
|
- () -> service.pickForOrder("M001", null, "3201", "BIZ-1"));
|
|
|
|
|
|
|
+ () -> service.pickForOrder("M001", null, null, "BIZ-1"));
|
|
|
|
|
|
|
|
assertEquals(400, e.getCode());
|
|
assertEquals(400, e.getCode());
|
|
|
- assertEquals("省份编码不能为空: 传了 city_code 就必须一并传 province_code", e.getMessage());
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 只传省份、省内多店、没传 out_biz_no ⇒ 随机种子,仍要散开(不能退化成固定第一家) */
|
|
|
@Test
|
|
@Test
|
|
|
- void pickForOrder_noUsableShopInProvince_400_namesTheProvince() {
|
|
|
|
|
- when(shopMapper.selectList(any())).thenReturn(List.of(ok("HZ", "33", "3301")));
|
|
|
|
|
|
|
+ void pickForOrder_provinceOnly_blankSeed_spreadsAcrossCalls() {
|
|
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of(
|
|
|
|
|
+ ok("A", "32", "3201"),
|
|
|
|
|
+ ok("B", "32", "3201"),
|
|
|
|
|
+ ok("C", "32", "3205"),
|
|
|
|
|
+ ok("D", "32", "3205")));
|
|
|
|
|
|
|
|
|
|
+ Set<String> chosen = new HashSet<>();
|
|
|
|
|
+ for (int i = 0; i < 60; i++) {
|
|
|
|
|
+ chosen.add(service.pickForOrder("M001", "32", null, null).getThirdShopId());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(chosen.size() > 1, "没传 out_biz_no 时也必须是随机挑而不是固定第一家,实际: " + chosen);
|
|
|
|
|
+ assertTrue(Set.of("A", "B", "C", "D").containsAll(chosen));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 稳定散列的承诺不变:给了 out_biz_no,同一单重复挑落同一家(开放文档 10.2 写死了这条) */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void pickForOrder_sameOutBizNo_isStable() {
|
|
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of(
|
|
|
|
|
+ ok("A", "32", "3201"),
|
|
|
|
|
+ ok("B", "32", "3201"),
|
|
|
|
|
+ ok("C", "32", "3205"),
|
|
|
|
|
+ ok("D", "32", "3205")));
|
|
|
|
|
+
|
|
|
|
|
+ String first = service.pickForOrder("M001", "32", null, "BIZ-1").getThirdShopId();
|
|
|
|
|
+ for (int i = 0; i < 10; i++) {
|
|
|
|
|
+ assertEquals(first, service.pickForOrder("M001", "32", null, "BIZ-1").getThirdShopId());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void pickForOrder_provinceOnly_stillPicksWithinProvince() {
|
|
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of(
|
|
|
|
|
+ ok("SZ", "32", "3205"),
|
|
|
|
|
+ ok("HZ", "33", "3301")));
|
|
|
|
|
+
|
|
|
|
|
+ HarShopEntity picked = service.pickForOrder("M001", "32", null, "BIZ-1");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("SZ", picked.getThirdShopId(), "不给 IP ⇒ 按全省挑,异省的店绝不越界");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void pickForOrder_unknownProvinceCode_400_sameMessageAsShopCreate() {
|
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
|
- () -> service.pickForOrder("M001", "32", null, "BIZ-1"));
|
|
|
|
|
|
|
+ () -> service.pickForOrder("M001", "99", null, "BIZ-1"));
|
|
|
|
|
|
|
|
- assertEquals("该地区没有可用门店: 省 32", e.getMessage());
|
|
|
|
|
|
|
+ assertEquals(400, e.getCode());
|
|
|
|
|
+ assertEquals("省份编码不存在: 99", e.getMessage(),
|
|
|
|
|
+ "与门店创建那条必须一字不差 —— 开放文档 10.1/10.2 引用的是同一句话");
|
|
|
|
|
+ verify(shopMapper, org.mockito.Mockito.never()).selectList(any());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
@Test
|
|
|
- void pickForOrder_noUsableShopWithCity_400_namesTheCity() {
|
|
|
|
|
- when(shopMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
|
|
+ void pickForOrder_noUsableShopInProvince_400_namesTheProvince() {
|
|
|
|
|
+ when(shopMapper.selectList(any())).thenReturn(List.of(ok("HZ", "33", "3301")));
|
|
|
|
|
|
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
|
- () -> service.pickForOrder("M001", "32", "3201", "BIZ-1"));
|
|
|
|
|
|
|
+ () -> service.pickForOrder("M001", "32", null, "BIZ-1"));
|
|
|
|
|
|
|
|
- assertEquals("该地区没有可用门店: 3201(省 32)", e.getMessage());
|
|
|
|
|
|
|
+ assertEquals("该地区没有可用门店: 省 32", e.getMessage());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -337,8 +429,8 @@ class HarShopRegionPickTest {
|
|
|
shop(null, "32", "3201", HarShopCreateStatus.SUCCESS, "S-PLATFORM")));
|
|
shop(null, "32", "3201", HarShopCreateStatus.SUCCESS, "S-PLATFORM")));
|
|
|
|
|
|
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
BusinessException e = assertThrows(BusinessException.class,
|
|
|
- () -> service.pickForOrder("M001", "32", "3201", "BIZ-1"));
|
|
|
|
|
|
|
+ () -> service.pickForOrder("M001", "32", null, "BIZ-1"));
|
|
|
|
|
|
|
|
- assertEquals("该地区没有可用门店: 3201(省 32)", e.getMessage());
|
|
|
|
|
|
|
+ assertEquals("该地区没有可用门店: 省 32", e.getMessage());
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|