|
@@ -10,9 +10,14 @@ import com.payment.platform.module.payment.har.dto.HarCheckoutPayDTO;
|
|
|
import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
|
|
import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
|
|
|
import com.payment.platform.module.payment.har.dto.HarOrderVO;
|
|
import com.payment.platform.module.payment.har.dto.HarOrderVO;
|
|
|
import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
|
|
import com.payment.platform.module.payment.har.entity.HarCheckoutLinkEntity;
|
|
|
|
|
+import com.payment.platform.module.payment.har.entity.HarShopEntity;
|
|
|
import com.payment.platform.module.payment.har.service.HarCheckoutLinkService;
|
|
import com.payment.platform.module.payment.har.service.HarCheckoutLinkService;
|
|
|
|
|
+import com.payment.platform.module.payment.har.service.HarIpRegionResolver;
|
|
|
|
|
+import com.payment.platform.module.payment.har.service.HarIpRegionResolver.IpGeo;
|
|
|
import com.payment.platform.module.payment.har.service.HarMerchantService;
|
|
import com.payment.platform.module.payment.har.service.HarMerchantService;
|
|
|
import com.payment.platform.module.payment.har.service.HarOrderService;
|
|
import com.payment.platform.module.payment.har.service.HarOrderService;
|
|
|
|
|
+import com.payment.platform.module.payment.har.service.HarShopService;
|
|
|
|
|
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
|
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.AfterEach;
|
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.Test;
|
|
@@ -20,12 +25,14 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
import org.mockito.ArgumentCaptor;
|
|
import org.mockito.ArgumentCaptor;
|
|
|
import org.mockito.Mock;
|
|
import org.mockito.Mock;
|
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
+import org.springframework.mock.web.MockHttpServletRequest;
|
|
|
import org.springframework.security.core.Authentication;
|
|
import org.springframework.security.core.Authentication;
|
|
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
|
|
|
|
|
import java.math.BigDecimal;
|
|
import java.math.BigDecimal;
|
|
|
import java.util.ArrayList;
|
|
import java.util.ArrayList;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
|
|
+import java.util.Optional;
|
|
|
|
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
@@ -52,7 +59,12 @@ import static org.mockito.Mockito.*;
|
|
|
* <li><b>上游失败只回一句顾客能照做的话</b>:上游原话(「下单失败: …」「调用上游失败: …」)
|
|
* <li><b>上游失败只回一句顾客能照做的话</b>:上游原话(「下单失败: …」「调用上游失败: …」)
|
|
|
* 一个字都不许出给顾客。上游失败在 HAR 会分成**两类**抛出({@code BusinessException}
|
|
* 一个字都不许出给顾客。上游失败在 HAR 会分成**两类**抛出({@code BusinessException}
|
|
|
* = 上游明确拒绝,{@code HarApiException} = 网络/解析/通道未启用且 {@code create}
|
|
* = 上游明确拒绝,{@code HarApiException} = 网络/解析/通道未启用且 {@code create}
|
|
|
- * 不捕它),两类都要收住。</li>
|
|
|
|
|
|
|
+ * 不捕它),两类都要收住;</li>
|
|
|
|
|
+ * <li><b>门店是按顾客 IP 的地区挑的</b>(2026-10-08,异地收款风控):解析 IP → 省 → 市 →
|
|
|
|
|
+ * 挑一家同地区门店 → 填进 {@code third_shop_code}。挑不出来**必须拒收**
|
|
|
|
|
+ * (不打上游、不落单),给错地区比拒收更糟 —— 那正是这套东西要规避的风控。
|
|
|
|
|
+ * 本类里地区目录用**真家伙**(纯内存、读打进包里的快照),只有 IP 那一跳与门店查询
|
|
|
|
|
+ * 是 mock 的。</li>
|
|
|
* </ol>
|
|
* </ol>
|
|
|
*
|
|
*
|
|
|
* <p>★ <b>本类覆盖不到、由别的用例守着的那半边</b>:{@code pay_har_checkout_link} 必须登记在
|
|
* <p>★ <b>本类覆盖不到、由别的用例守着的那半边</b>:{@code pay_har_checkout_link} 必须登记在
|
|
@@ -66,18 +78,47 @@ class HarCheckoutControllerTest {
|
|
|
private static final Long TENANT = 42L;
|
|
private static final Long TENANT = 42L;
|
|
|
private static final String MCH = "M001";
|
|
private static final String MCH = "M001";
|
|
|
|
|
|
|
|
|
|
+ /** 顾客 IP —— 既是被解析的对象,也是挑门店时的散列种子 */
|
|
|
|
|
+ private static final String IP = "114.114.114.114";
|
|
|
|
|
+
|
|
|
@Mock private HarCheckoutLinkService linkService;
|
|
@Mock private HarCheckoutLinkService linkService;
|
|
|
@Mock private HarMerchantService merchantService;
|
|
@Mock private HarMerchantService merchantService;
|
|
|
@Mock private HarOrderService orderService;
|
|
@Mock private HarOrderService orderService;
|
|
|
|
|
+ @Mock private HarIpRegionResolver ipRegionResolver;
|
|
|
|
|
+ @Mock private HarShopService shopService;
|
|
|
|
|
+
|
|
|
|
|
+ /** 真目录:纯内存 + 读打进包里的快照,mock 掉等于把整段省市匹配从这层摘走 */
|
|
|
|
|
+ private final HarRegionCatalog regionCatalog = new HarRegionCatalog();
|
|
|
|
|
|
|
|
private HarCheckoutController controller;
|
|
private HarCheckoutController controller;
|
|
|
|
|
|
|
|
@BeforeEach
|
|
@BeforeEach
|
|
|
void setUp() {
|
|
void setUp() {
|
|
|
- controller = new HarCheckoutController(linkService, merchantService, orderService);
|
|
|
|
|
|
|
+ controller = new HarCheckoutController(linkService, merchantService, orderService,
|
|
|
|
|
+ shopService, ipRegionResolver, regionCatalog);
|
|
|
SecurityContextHolder.clearContext();
|
|
SecurityContextHolder.clearContext();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 一个把 {@code remoteAddr} 设成 IP 的请求;要验请求头优先级时另造(见 clientIp 那组用例) */
|
|
|
|
|
+ private static MockHttpServletRequest request() {
|
|
|
|
|
+ MockHttpServletRequest req = new MockHttpServletRequest();
|
|
|
|
|
+ req.setRemoteAddr(IP);
|
|
|
|
|
+ return req;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 让这条腿一路走到下单:IP 解析成江苏南京,并按 (省 32, 市 3201) 挑到一家店。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return 被挑中的那家门店(用例要断言它的 {@code third_shop_code} 时取用)
|
|
|
|
|
+ */
|
|
|
|
|
+ private HarShopEntity stubRegionAndShop() {
|
|
|
|
|
+ when(ipRegionResolver.lookup(IP)).thenReturn(Optional.of(new IpGeo("CN", "江苏省", "JS", "南京")));
|
|
|
|
|
+ HarShopEntity shop = new HarShopEntity();
|
|
|
|
|
+ shop.setThirdShopId("SHOP-NJ");
|
|
|
|
|
+ when(shopService.pickForRegion(MCH, "32", "3201", IP)).thenReturn(shop);
|
|
|
|
|
+ return shop;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@AfterEach
|
|
@AfterEach
|
|
|
void tearDown() {
|
|
void tearDown() {
|
|
|
SecurityContextHolder.clearContext();
|
|
SecurityContextHolder.clearContext();
|
|
@@ -139,7 +180,7 @@ class HarCheckoutControllerTest {
|
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
|
- () -> controller.create("tok", pay("0.99")));
|
|
|
|
|
|
|
+ () -> controller.create("tok", pay("0.99"), request()));
|
|
|
|
|
|
|
|
assertEquals(400, ex.getCode());
|
|
assertEquals(400, ex.getCode());
|
|
|
assertTrue(ex.getMessage().contains("不能低于 1 元"), "实际: " + ex.getMessage());
|
|
assertTrue(ex.getMessage().contains("不能低于 1 元"), "实际: " + ex.getMessage());
|
|
@@ -152,7 +193,7 @@ class HarCheckoutControllerTest {
|
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
|
|
|
assertEquals(400, assertThrows(BusinessException.class,
|
|
assertEquals(400, assertThrows(BusinessException.class,
|
|
|
- () -> controller.create("tok", pay(null))).getCode());
|
|
|
|
|
|
|
+ () -> controller.create("tok", pay(null), request())).getCode());
|
|
|
verifyNoInteractions(merchantService, orderService);
|
|
verifyNoInteractions(merchantService, orderService);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -162,7 +203,7 @@ class HarCheckoutControllerTest {
|
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
|
|
|
assertEquals(400, assertThrows(BusinessException.class,
|
|
assertEquals(400, assertThrows(BusinessException.class,
|
|
|
- () -> controller.create("tok", null)).getCode());
|
|
|
|
|
|
|
+ () -> controller.create("tok", null, request())).getCode());
|
|
|
verifyNoInteractions(merchantService, orderService);
|
|
verifyNoInteractions(merchantService, orderService);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -170,9 +211,10 @@ class HarCheckoutControllerTest {
|
|
|
@Test
|
|
@Test
|
|
|
void create_floorExactlyOneYuan_isAccepted() {
|
|
void create_floorExactlyOneYuan_isAccepted() {
|
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ stubRegionAndShop();
|
|
|
when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
|
|
|
|
|
|
- Result<?> r = controller.create("tok", pay("1"));
|
|
|
|
|
|
|
+ Result<?> r = controller.create("tok", pay("1"), request());
|
|
|
|
|
|
|
|
assertEquals(0, r.getCode(), "1 元整必须放行");
|
|
assertEquals(0, r.getCode(), "1 元整必须放行");
|
|
|
ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
|
|
ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
|
|
@@ -183,9 +225,10 @@ class HarCheckoutControllerTest {
|
|
|
@Test
|
|
@Test
|
|
|
void create_assemblesTheOrderFromTheLinkRow_andDelegatesWithNullEnterpriseAndApiKey() {
|
|
void create_assemblesTheOrderFromTheLinkRow_andDelegatesWithNullEnterpriseAndApiKey() {
|
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ stubRegionAndShop();
|
|
|
when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
|
|
|
|
|
|
- controller.create("tok", pay("12.34"));
|
|
|
|
|
|
|
+ controller.create("tok", pay("12.34"), request());
|
|
|
|
|
|
|
|
ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
|
|
ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
|
|
|
verify(orderService).create(captor.capture(), isNull(), isNull());
|
|
verify(orderService).create(captor.capture(), isNull(), isNull());
|
|
@@ -210,24 +253,205 @@ class HarCheckoutControllerTest {
|
|
|
assertNull(create.getAuthCode(), "没有付款码 ⇒ 主扫(precreate),不是被扫(pay)");
|
|
assertNull(create.getAuthCode(), "没有付款码 ⇒ 主扫(precreate),不是被扫(pay)");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ─────────────────────── ③ 门店按顾客 IP 的地区挑(2026-10-08,异地收款风控) ───────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 挑中的门店编码必须填进 {@code third_shop_code}。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>为什么是这一格:订单创建参数表里 {@code third_shop_code}(商户自己的门店编号)是
|
|
|
|
|
+ * **√ 必填**,{@code shop_code}(平台门店编号)才是选填;而按顾客所在地挑店正是本功能的
|
|
|
|
|
+ * 全部意义 —— 用错地区的门店下单,支付宝会按异地收款风控拦。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>stub 写的是**精确匹配**的 {@code (MCH,"32","3201",IP)} ⇒ 参数错一个,它返回 null,
|
|
|
|
|
+ * 这条腿当场 400,断言立刻红。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void create_fillsInTheShopPickedForTheCustomerRegion() {
|
|
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ stubRegionAndShop();
|
|
|
|
|
+ when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
|
|
|
+
|
|
|
|
|
+ controller.create("tok", pay("5"), request());
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
|
|
|
|
|
+ verify(orderService).create(captor.capture(), isNull(), isNull());
|
|
|
|
|
+ assertEquals("SHOP-NJ", captor.getValue().getThirdShopCode());
|
|
|
|
|
+ verify(shopService).pickForRegion(MCH, "32", "3201", IP);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 市名是**区级**(实测 ipwho.is 对重庆回的就是 {@code 渝中區})⇒ 市级匹配不上,
|
|
|
|
|
+ * 退到**省级**:挑店时 cityCode 传 null,而不是编一个或干脆拒收。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void create_districtLevelCityName_fallsBackToTheProvinceTier() {
|
|
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ when(ipRegionResolver.lookup(IP))
|
|
|
|
|
+ .thenReturn(Optional.of(new IpGeo("CN", "重庆市", "CQ", "渝中區")));
|
|
|
|
|
+ when(shopService.pickForRegion(MCH, "50", null, IP)).thenReturn(shopWith("SHOP-CQ"));
|
|
|
|
|
+ when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
|
|
|
+
|
|
|
|
|
+ controller.create("tok", pay("5"), request());
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
|
|
|
|
|
+ verify(orderService).create(captor.capture(), isNull(), isNull());
|
|
|
|
|
+ assertEquals("SHOP-CQ", captor.getValue().getThirdShopCode());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 繁体省名同样要走得通 —— 实测 ipwho.is 回的西藏就是「西藏自治區」 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void create_traditionalProvinceName_stillRoutes() {
|
|
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ when(ipRegionResolver.lookup(IP))
|
|
|
|
|
+ .thenReturn(Optional.of(new IpGeo("CN", "西藏自治區", "XZ", "拉薩")));
|
|
|
|
|
+ when(shopService.pickForRegion(MCH, "54", "5401", IP)).thenReturn(shopWith("SHOP-XZ"));
|
|
|
|
|
+ when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
|
|
|
+
|
|
|
|
|
+ controller.create("tok", pay("5"), request());
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
|
|
|
|
|
+ verify(orderService).create(captor.capture(), isNull(), isNull());
|
|
|
|
|
+ assertEquals("SHOP-XZ", captor.getValue().getThirdShopCode());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 解析不出地区 ⇒ **拒收**:一次门店查询、一次上游调用都不发生。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>文案里不许出现 IP 或内部词汇 —— 顾客页拿到什么就显示什么(同 F3 那条)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void create_regionUnresolved_400_noShopQueryNoOrderLeg() {
|
|
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ when(ipRegionResolver.lookup(IP)).thenReturn(Optional.empty());
|
|
|
|
|
+
|
|
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
|
|
+ () -> controller.create("tok", pay("5"), request()));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
|
|
+ assertEquals("无法确定您当前所在地区,暂时无法收款,请换用移动网络或联系收款方", ex.getMessage());
|
|
|
|
|
+ assertFalse(ex.getMessage().contains(IP), "不许把顾客 IP 带上屏");
|
|
|
|
|
+ verifyNoInteractions(shopService, orderService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 省名我们目录里没有(上游给了个生名字)—— 对顾客与「解析不出来」是同一句话,不细分 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void create_unknownProvinceName_400_customerWording() {
|
|
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ when(ipRegionResolver.lookup(IP))
|
|
|
|
|
+ .thenReturn(Optional.of(new IpGeo("CN", "Atlantis", "AT", "Nowhere")));
|
|
|
|
|
+
|
|
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
|
|
+ () -> controller.create("tok", pay("5"), request()));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
|
|
+ assertFalse(ex.getMessage().contains("Atlantis"), "上游原文不许上屏");
|
|
|
|
|
+ verifyNoInteractions(shopService, orderService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 地区解析出来了,但那个地区**一家能用的店都没有** ⇒ 同样拒收,但**文案不同**:
|
|
|
|
|
+ * 「认不出地区」顾客换个网络或许能好,「这个地区没开通」只有收款方能推 ——
|
|
|
|
|
+ * 合并成一句话就等于让顾客去做一件他做不到的事。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void create_noUsableShopInThatRegion_400_distinctWording() {
|
|
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ when(ipRegionResolver.lookup(IP)).thenReturn(Optional.of(new IpGeo("CN", "江苏省", "JS", "南京")));
|
|
|
|
|
+ when(shopService.pickForRegion(MCH, "32", "3201", IP)).thenReturn(null);
|
|
|
|
|
+
|
|
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
|
|
+ () -> controller.create("tok", pay("5"), request()));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(400, ex.getCode());
|
|
|
|
|
+ assertEquals("收款方暂未开通您所在地区的收款,请联系收款方", ex.getMessage());
|
|
|
|
|
+ assertNotEquals("无法确定您当前所在地区,暂时无法收款,请换用移动网络或联系收款方", ex.getMessage(),
|
|
|
|
|
+ "两种处境必须给不同的话");
|
|
|
|
|
+ verifyNoInteractions(orderService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 门店挑选**必须**跑在租户身份下:{@code pay_har_shop} 也在
|
|
|
|
|
+ * {@code TenantInnerInterceptor.CONDITIONAL_TABLES} 里(见上一个用例的说明)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void create_shopPickRunsUnderTheLinkTenant() {
|
|
|
|
|
+ when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ when(ipRegionResolver.lookup(IP)).thenReturn(Optional.of(new IpGeo("CN", "江苏省", "JS", "南京")));
|
|
|
|
|
+ when(shopService.pickForRegion(MCH, "32", "3201", IP)).thenAnswer(inv -> {
|
|
|
|
|
+ assertEquals(TENANT, currentTenantId(), "挑店读的是 pay_har_shop,必须带租户");
|
|
|
|
|
+ return shopWith("SHOP-NJ");
|
|
|
|
|
+ });
|
|
|
|
|
+ when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
|
|
|
+
|
|
|
|
|
+ controller.create("tok", pay("5"), request());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ─────────────────────── ④ 顾客 IP 从哪来 ───────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /** 多级代理下第一跳才是顾客,后面几跳是我们自己的网关 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void clientIp_takesTheFirstHopOfXForwardedFor() {
|
|
|
|
|
+ MockHttpServletRequest req = new MockHttpServletRequest();
|
|
|
|
|
+ req.addHeader("X-Forwarded-For", " 203.0.113.9 , 10.0.0.1 ");
|
|
|
|
|
+ req.addHeader("X-Real-IP", "198.51.100.7");
|
|
|
|
|
+ req.setRemoteAddr("10.0.0.1");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("203.0.113.9", HarCheckoutController.clientIp(req));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void clientIp_fallsBackToXRealIpThenRemoteAddr() {
|
|
|
|
|
+ MockHttpServletRequest xri = new MockHttpServletRequest();
|
|
|
|
|
+ xri.addHeader("X-Real-IP", "198.51.100.7");
|
|
|
|
|
+ xri.setRemoteAddr("10.0.0.1");
|
|
|
|
|
+ assertEquals("198.51.100.7", HarCheckoutController.clientIp(xri));
|
|
|
|
|
+
|
|
|
|
|
+ MockHttpServletRequest bare = new MockHttpServletRequest();
|
|
|
|
|
+ bare.setRemoteAddr("203.0.113.9");
|
|
|
|
|
+ assertEquals("203.0.113.9", HarCheckoutController.clientIp(bare));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 有的反代没拿到地址时写字面量 {@code unknown} —— 它不是地址,别拿去查归属地 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void clientIp_ignoresLiteralUnknown() {
|
|
|
|
|
+ MockHttpServletRequest req = new MockHttpServletRequest();
|
|
|
|
|
+ req.addHeader("X-Forwarded-For", "unknown");
|
|
|
|
|
+ req.addHeader("X-Real-IP", "198.51.100.7");
|
|
|
|
|
+ req.setRemoteAddr("10.0.0.1");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("198.51.100.7", HarCheckoutController.clientIp(req));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static HarShopEntity shopWith(String thirdShopId) {
|
|
|
|
|
+ HarShopEntity shop = new HarShopEntity();
|
|
|
|
|
+ shop.setThirdShopId(thirdShopId);
|
|
|
|
|
+ return shop;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@Test
|
|
@Test
|
|
|
void create_setsTenantContextForEveryDbOperationAndRestoresItAfterwards() {
|
|
void create_setsTenantContextForEveryDbOperationAndRestoresItAfterwards() {
|
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ when(ipRegionResolver.lookup(IP)).thenReturn(Optional.of(new IpGeo("CN", "江苏省", "JS", "南京")));
|
|
|
|
|
|
|
|
List<Long> seen = new ArrayList<>();
|
|
List<Long> seen = new ArrayList<>();
|
|
|
when(merchantService.requireByTenantAndCode(TENANT, MCH)).thenAnswer(inv -> {
|
|
when(merchantService.requireByTenantAndCode(TENANT, MCH)).thenAnswer(inv -> {
|
|
|
seen.add(currentTenantId());
|
|
seen.add(currentTenantId());
|
|
|
return null;
|
|
return null;
|
|
|
});
|
|
});
|
|
|
|
|
+ // 挑店读的是 pay_har_shop,它也在 CONDITIONAL_TABLES 里 —— 这一跳同样要在租户身份下。
|
|
|
|
|
+ when(shopService.pickForRegion(MCH, "32", "3201", IP)).thenAnswer(inv -> {
|
|
|
|
|
+ seen.add(currentTenantId());
|
|
|
|
|
+ return shopWith("SHOP-NJ");
|
|
|
|
|
+ });
|
|
|
when(orderService.create(any(), any(), any())).thenAnswer(inv -> {
|
|
when(orderService.create(any(), any(), any())).thenAnswer(inv -> {
|
|
|
seen.add(currentTenantId());
|
|
seen.add(currentTenantId());
|
|
|
return paidVo("https://pay/x");
|
|
return paidVo("https://pay/x");
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- controller.create("tok", pay("5"));
|
|
|
|
|
|
|
+ controller.create("tok", pay("5"), request());
|
|
|
|
|
|
|
|
- assertEquals(List.of(TENANT, TENANT), seen,
|
|
|
|
|
- "本地闸与下单腿都必须跑在「该链接所属租户」的身份下 —— 得到的是拦截器那一条"
|
|
|
|
|
|
|
+ assertEquals(List.of(TENANT, TENANT, TENANT), seen,
|
|
|
|
|
+ "本地闸、挑店与下单腿都必须跑在「该链接所属租户」的身份下 —— 得到的是拦截器那一条"
|
|
|
+ "独立写出的租户谓词(纵深防御:手写 WHERE 写错时另一条还在)");
|
|
+ "独立写出的租户谓词(纵深防御:手写 WHERE 写错时另一条还在)");
|
|
|
assertNull(SecurityContextHolder.getContext().getAuthentication(),
|
|
assertNull(SecurityContextHolder.getContext().getAuthentication(),
|
|
|
"请求结束后必须把上下文还原(这是容器的线程,不是我们自己的线程)");
|
|
"请求结束后必须把上下文还原(这是容器的线程,不是我们自己的线程)");
|
|
@@ -246,7 +470,7 @@ class HarCheckoutControllerTest {
|
|
|
.thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: " + MCH));
|
|
.thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: " + MCH));
|
|
|
|
|
|
|
|
assertEquals(404, assertThrows(BusinessException.class,
|
|
assertEquals(404, assertThrows(BusinessException.class,
|
|
|
- () -> controller.create("tok", pay("5"))).getCode());
|
|
|
|
|
|
|
+ () -> controller.create("tok", pay("5"), request())).getCode());
|
|
|
verifyNoInteractions(orderService);
|
|
verifyNoInteractions(orderService);
|
|
|
assertNull(SecurityContextHolder.getContext().getAuthentication(),
|
|
assertNull(SecurityContextHolder.getContext().getAuthentication(),
|
|
|
"抛出的那条路径也要还原上下文(finally)");
|
|
"抛出的那条路径也要还原上下文(finally)");
|
|
@@ -270,7 +494,7 @@ class HarCheckoutControllerTest {
|
|
|
.thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: " + MCH));
|
|
.thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: " + MCH));
|
|
|
|
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
BusinessException ex = assertThrows(BusinessException.class,
|
|
|
- () -> controller.create("tok", pay("5")));
|
|
|
|
|
|
|
+ () -> controller.create("tok", pay("5"), request()));
|
|
|
|
|
|
|
|
assertEquals(404, ex.getCode());
|
|
assertEquals(404, ex.getCode());
|
|
|
assertEquals("收款方暂时无法收款,请稍后重试或联系收款方", ex.getMessage(),
|
|
assertEquals("收款方暂时无法收款,请稍后重试或联系收款方", ex.getMessage(),
|
|
@@ -283,10 +507,11 @@ class HarCheckoutControllerTest {
|
|
|
@Test
|
|
@Test
|
|
|
void create_upstreamRejects_returnsBusinessFailure_withoutUpstreamWording() {
|
|
void create_upstreamRejects_returnsBusinessFailure_withoutUpstreamWording() {
|
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ stubRegionAndShop();
|
|
|
when(orderService.create(any(), any(), any()))
|
|
when(orderService.create(any(), any(), any()))
|
|
|
.thenThrow(new BusinessException("下单失败: 上游返回 40004: 参数错误"));
|
|
.thenThrow(new BusinessException("下单失败: 上游返回 40004: 参数错误"));
|
|
|
|
|
|
|
|
- Result<?> r = controller.create("tok", pay("5"));
|
|
|
|
|
|
|
+ Result<?> r = controller.create("tok", pay("5"), request());
|
|
|
|
|
|
|
|
assertEquals(500, r.getCode());
|
|
assertEquals(500, r.getCode());
|
|
|
assertEquals("支付发起失败,请稍后重试", r.getMsg());
|
|
assertEquals("支付发起失败,请稍后重试", r.getMsg());
|
|
@@ -301,10 +526,11 @@ class HarCheckoutControllerTest {
|
|
|
@Test
|
|
@Test
|
|
|
void create_upstreamTransportFailure_returnsBusinessFailure_too() {
|
|
void create_upstreamTransportFailure_returnsBusinessFailure_too() {
|
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ stubRegionAndShop();
|
|
|
when(orderService.create(any(), any(), any()))
|
|
when(orderService.create(any(), any(), any()))
|
|
|
.thenThrow(new HarApiException("调用上游失败: connection reset"));
|
|
.thenThrow(new HarApiException("调用上游失败: connection reset"));
|
|
|
|
|
|
|
|
- Result<?> r = controller.create("tok", pay("5"));
|
|
|
|
|
|
|
+ Result<?> r = controller.create("tok", pay("5"), request());
|
|
|
|
|
|
|
|
assertEquals(500, r.getCode());
|
|
assertEquals(500, r.getCode());
|
|
|
assertEquals("支付发起失败,请稍后重试", r.getMsg());
|
|
assertEquals("支付发起失败,请稍后重试", r.getMsg());
|
|
@@ -313,9 +539,10 @@ class HarCheckoutControllerTest {
|
|
|
@Test
|
|
@Test
|
|
|
void create_blankPayUrl_returns500() {
|
|
void create_blankPayUrl_returns500() {
|
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ stubRegionAndShop();
|
|
|
when(orderService.create(any(), any(), any())).thenReturn(paidVo(null));
|
|
when(orderService.create(any(), any(), any())).thenReturn(paidVo(null));
|
|
|
|
|
|
|
|
- Result<?> r = controller.create("tok", pay("5"));
|
|
|
|
|
|
|
+ Result<?> r = controller.create("tok", pay("5"), request());
|
|
|
|
|
|
|
|
assertEquals(500, r.getCode(), "主扫的全部意义就是那条链接,没有它这笔单在页面上画不出来");
|
|
assertEquals(500, r.getCode(), "主扫的全部意义就是那条链接,没有它这笔单在页面上画不出来");
|
|
|
assertEquals("支付发起失败,请稍后重试", r.getMsg());
|
|
assertEquals("支付发起失败,请稍后重试", r.getMsg());
|
|
@@ -324,9 +551,10 @@ class HarCheckoutControllerTest {
|
|
|
@Test
|
|
@Test
|
|
|
void create_success_returnsOrderNoPayUrlAndPrice() {
|
|
void create_success_returnsOrderNoPayUrlAndPrice() {
|
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
when(linkService.requireByToken("tok")).thenReturn(link());
|
|
|
|
|
+ stubRegionAndShop();
|
|
|
when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
|
|
|
|
|
|
|
|
- Result<?> r = controller.create("tok", pay("12.34"));
|
|
|
|
|
|
|
+ Result<?> r = controller.create("tok", pay("12.34"), request());
|
|
|
|
|
|
|
|
assertEquals(0, r.getCode());
|
|
assertEquals(0, r.getCode());
|
|
|
HarCheckoutOrderVO vo = (HarCheckoutOrderVO) r.getData();
|
|
HarCheckoutOrderVO vo = (HarCheckoutOrderVO) r.getData();
|