Răsfoiți Sursa

feat(汇安融): 收款链接按顾客 IP 挑门店(二)—— 结账腿接线

支付宝对异地收款有风控,所以顾客页下单要用**顾客所在地**的门店去 precreate。
本刀把这条腿接上:IP →(HarIpRegionResolver)省名/市名 →(HarRegionCatalog)省码/市码
→(HarShopService#pickForRegion)一家门店 → 填进订单的 third_shop_code。

三条不显然但必须如此的:

- 挑不出来就拒收(400,不打上游、不落单)。给错地区的门店正是要规避的风控;
- 两种拒收文案必须不同:「认不出你在哪」(换网络或许能好)与「这个地区没开通」
  (只有收款方能推)合并成一句,就等于让顾客去做一件他做不到的事;
- 这一跳排在**本地闸之后、下单腿之前**:闸是本地一次查询,IP 解析要打一次第三方
  (3s 量级),能立刻失败的就别先花那个 RTT;而它又必须在下单之前(产出
  third_shop_code)。它跑在绑好的租户身份下 —— pay_har_shop 也在 CONDITIONAL_TABLES 里。

另外:clientIp 与 TenantApiKeyAuthFilter#getClientIp 同形(四头 + unknown 兜底 + 只取
第一跳),此处破例做成包内可见的静态方法,好让 IP 的来源与优先级有用例盯着。
★ 已知边界:X-Forwarded-For 可被客户端伪造,除非前置代理覆写它;后果是落到收款方自己的
另一家门店,不涉及资金与租户边界,但确实能绕过本意(把异地伪装成本地)—— 要堵它得在网关上。

验证:整包 mvn test 1207/1207 绿(HarCheckoutControllerTest 24)。变异验证:4 个变异体
(挑到不填 / 两句话合并 / 不取第一跳 / 去掉 unknown 兜底)预测红集 6 条,实测恰为那 6 条,
其余 18 条作正对照仍绿;还原后 sha256 与变异前一致。
alphaH 1 săptămână în urmă
părinte
comite
32384b2ba2

+ 119 - 3
java/src/main/java/com/payment/platform/module/payment/har/controller/HarCheckoutController.java

@@ -11,12 +11,18 @@ 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.HarOrderVO;
 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.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.HarOrderService;
+import com.payment.platform.module.payment.har.service.HarShopService;
 import com.payment.platform.module.payment.har.util.HarAmountFloor;
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -102,6 +108,30 @@ import java.math.BigDecimal;
  * {@code HarClient} 里闸上游调用(关着时顾客看到的是「支付发起失败」,与收银台同一个形态)
  * —— 跟乐收赢不同,那边的开关决定整个模块装不装配。同 {@code HarCashierController}。
  *
+ * <h2>★★ 门店按顾客 IP 的所在地挑(2026-10-08,异地收款风控)</h2>
+ * 支付宝对**异地收款**有风控,所以主扫单要用**顾客所在地**的门店去 {@code precreate}。
+ * 门店自 2026-10-08 起带省份/市两个必填字段(规范 5.5),本类下单时走这条阶梯:
+ * IP →({@link HarIpRegionResolver})省名/市名 →({@link HarRegionCatalog})省码/市码 →
+ * ({@link HarShopService#pickForRegion})一家门店 → 填进 {@code third_shop_code}。
+ *
+ * <p>三条不显然但必须如此的规矩:
+ * <ol>
+ *   <li><b>挑不出来就拒收</b>(400,不打上游、不落单)。给错地区的门店正是这套东西要规避的
+ *       风控,而拒收对顾客只是一句能读懂的话 ⇒ 宁可拒收。两种拒收**文案不同**:
+ *       {@link #MSG_REGION_UNRESOLVED}(认不出你在哪 —— 换网络或许能好)与
+ *       {@link #MSG_NO_SHOP_IN_REGION}(这个地区没开通 —— 只有收款方能推);</li>
+ *   <li><b>这一跳排在本地闸之后、下单腿之前</b>:本地闸是一次本地库查询,IP 解析是一次
+ *       三秒量级的第三方调用 —— 能立刻失败的就别先花那个 RTT。而它又必须在下单腿之前,
+ *       因为 {@code third_shop_code} 是下单参数;</li>
+ *   <li><b>它跑在绑好的租户身份下</b>:挑店读的是 {@code pay_har_shop},那张表也在
+ *       {@code CONDITIONAL_TABLES} 里(见上一条)⇒ 用别的租户的店下单必然被上游拒。</li>
+ * </ol>
+ *
+ * <p>★ <b>已知的边界</b>:{@link #clientIp} 信 {@code X-Forwarded-For} 的第一跳,
+ * 那个头是**客户端可伪造**的 —— 除非前置代理会覆写它。伪造它的后果是「落到收款方**自己**的
+ * 另一家门店」,不涉及资金流向与租户边界(挑选只在链接那一行的商户之下进行),
+ * 但确实能绕过本意(把异地收款伪装成本地)。要堵它得由网关覆写该头,不是这一层能判的。
+ *
  * <h2>端点清单</h2>
  * <table border="1">
  *   <caption>两个端点都不带权限串(免登录)</caption>
@@ -154,9 +184,31 @@ public class HarCheckoutController {
      */
     private static final String MSG_MERCHANT_UNAVAILABLE = "收款方暂时无法收款,请稍后重试或联系收款方";
 
+    /**
+     * 判不出顾客所在地时给顾客的一句话(2026-10-08)。
+     *
+     * <p>「换用移动网络」是顾客**真能照做**的动作(很多代理/VPN 出口在境外,而境外地址
+     * 一律判空:见 {@code HarIpRegionResolver} 只放行 {@code CN})。不说「稍后重试」——
+     * 同一张网重按一百次结果一样。
+     */
+    private static final String MSG_REGION_UNRESOLVED =
+            "无法确定您当前所在地区,暂时无法收款,请换用移动网络或联系收款方";
+
+    /**
+     * 地区判出来了、但那个地区没有能用的门店时给顾客的一句话(2026-10-08)。
+     *
+     * <p>★ 与 {@link #MSG_REGION_UNRESOLVED} **必须不同**:这里顾客怎么做都没用,
+     * 只有收款方能推(去建一家该地区的门店)。合并成一句话就等于让他去换网络、
+     * 换完还是不行,而收款方那边永远收不到任何信号。
+     */
+    private static final String MSG_NO_SHOP_IN_REGION = "收款方暂未开通您所在地区的收款,请联系收款方";
+
     private final HarCheckoutLinkService linkService;
     private final HarMerchantService merchantService;
     private final HarOrderService orderService;
+    private final HarShopService shopService;
+    private final HarIpRegionResolver ipRegionResolver;
+    private final HarRegionCatalog regionCatalog;
 
     // ──────────────────────────── 1. 开屏:显示商户名 ────────────────────────────
 
@@ -187,9 +239,10 @@ public class HarCheckoutController {
      * <ol>
      *   <li><b>解析 token</b> —— 免登录读,未认证状态下按全局唯一的 token 定位(类 javadoc);</li>
      *   <li><b>金额下限</b> —— 见下面的双保险说明;</li>
-     *   <li><b>绑租户 → 本地闸 → 下单腿</b> —— 本地闸必须排在**任何上游调用之前**:
+     *   <li><b>绑租户 → 本地闸 → 按 IP 挑店 → 下单腿</b> —— 本地闸必须排在**任何上游调用之前**:
      *       反了不是「同样安全」,那等于拿一个还没证明属于本租户的商户编号去发起一次上游调用
-     *       (同收银台与开放 API 那条腿的规矩);</li>
+     *       (同收银台与开放 API 那条腿的规矩)。挑店排在闸**之后**(那是本地一次查询,
+     *       而 IP 解析要打一次第三方)、下单**之前**(它产出 {@code third_shop_code});</li>
      *   <li><b>还原上下文</b>({@code finally})。</li>
      * </ol>
      *
@@ -207,7 +260,8 @@ public class HarCheckoutController {
     @PostMapping("/{token}/orders")
     @Operation(summary = "顾客页下单:按输入金额建主扫单并回支付链接(免登录)")
     public Result<HarCheckoutOrderVO> create(@PathVariable String token,
-                                             @Valid @RequestBody HarCheckoutPayDTO dto) {
+                                             @Valid @RequestBody HarCheckoutPayDTO dto,
+                                             HttpServletRequest request) {
         HarCheckoutLinkEntity link = linkService.requireByToken(token);
 
         BigDecimal price = dto == null ? null : dto.getPrice();
@@ -239,6 +293,8 @@ public class HarCheckoutController {
             create.setTradeType("alipay_un_stage");
             create.setBiz("har_ali_un_stage");
             create.setPayTimeout(PAY_TIMEOUT_MINUTES);
+            // 顾客所在地的门店(异地收款风控,见类 javadoc):挑不出来这里就抛 400。
+            create.setThirdShopCode(pickShopForCustomer(link, clientIp(request)).getThirdShopId());
             // 企业号与 apiKey 有意留空:顾客页不是下游系统,归属只来自上面绑的那个租户。
 
             HarOrderVO vo;
@@ -264,6 +320,66 @@ public class HarCheckoutController {
         }
     }
 
+    // ──────────────────── 按顾客 IP 挑门店(异地收款风控,2026-10-08) ────────────────────
+
+    /**
+     * IP → 省 → 市 → 一家可用门店。**挑不出来就抛 400**(两种处境两句话,见类 javadoc)。
+     *
+     * <p>市这一级允许落空({@code cityCode = null}):上游 IP 库常给区级名(实测重庆回
+     * {@code 渝中區}),对不上目录时正确的动作是**退到省**,不是编一个市码、也不是拒收 ——
+     * 顾客本就不该因为「我们没认出那个区名」付不了款。
+     *
+     * <p>日志带省市与挑中的门店编号(排障要的),**不带 IP**:IP 是个人信息,
+     * {@code HarIpRegionResolver} 自己那份日志已经掩码。
+     */
+    private HarShopEntity pickShopForCustomer(HarCheckoutLinkEntity link, String ip) {
+        IpGeo geo = ipRegionResolver.lookup(ip).orElseThrow(() -> {
+            log.warn("收款链接下单判不出顾客所在地: linkId={}", link.getId());
+            return new BusinessException(400, MSG_REGION_UNRESOLVED);
+        });
+        HarRegionCatalog.Province province = regionCatalog.matchProvince(geo.regionName()).orElseThrow(() -> {
+            log.warn("收款链接下单: IP 库给的省名不在目录里 linkId={}, region={}",
+                    link.getId(), geo.regionName());
+            return new BusinessException(400, MSG_REGION_UNRESOLVED);
+        });
+        String cityCode = regionCatalog.matchCity(province.code(), geo.cityName())
+                .map(HarRegionCatalog.City::code)
+                .orElse(null);
+        HarShopEntity shop = shopService.pickForRegion(
+                link.getMerchantCode(), province.code(), cityCode, ip);
+        if (shop == null || StrUtil.isBlank(shop.getThirdShopId())) {
+            log.warn("收款链接下单: 该地区没有可用门店 linkId={}, merchantCode={}, 省={}, 市={}, 顾客市名={}",
+                    link.getId(), link.getMerchantCode(), province.code(),
+                    cityCode == null ? "-" : cityCode, geo.cityName());
+            throw new BusinessException(400, MSG_NO_SHOP_IN_REGION);
+        }
+        log.info("收款链接下单按顾客地区挑店: linkId={}, 省={}, 市={}, thirdShopId={}",
+                link.getId(), province.code(), cityCode == null ? "-" : cityCode, shop.getThirdShopId());
+        return shop;
+    }
+
+    /**
+     * 顾客的真实 IP。
+     *
+     * <p>与 {@code TenantApiKeyAuthFilter#getClientIp} 同形(四个请求头 + {@code unknown}
+     * 兜底 + 只取第一跳)—— 本仓那条约定是各持一份私有副本。这里破例做成**包内可见的静态
+     * 方法**:IP 的来源与优先级是这条腿上一段有实际后果的判定,得有用例盯着
+     * ({@code HarCheckoutControllerTest} 的 clientIp 那组)。
+     *
+     * <p>★ 多级代理下只有第一跳是顾客,后面几跳是我们自己的网关。★ 这个头可被客户端
+     * 伪造,边界与后果见类 javadoc「已知的边界」。
+     */
+    static String clientIp(HttpServletRequest request) {
+        String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
+        for (String h : headers) {
+            String ip = request.getHeader(h);
+            if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
+                return ip.split(",")[0].trim();
+            }
+        }
+        return request.getRemoteAddr();
+    }
+
     // ──────────────────────────── 租户上下文 ────────────────────────────
 
     /**

+ 244 - 16
java/src/test/java/com/payment/platform/module/payment/har/controller/HarCheckoutControllerTest.java

@@ -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.HarOrderVO;
 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.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.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.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -20,12 +25,14 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
 
 import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
@@ -52,7 +59,12 @@ import static org.mockito.Mockito.*;
  *   <li><b>上游失败只回一句顾客能照做的话</b>:上游原话(「下单失败: …」「调用上游失败: …」)
  *       一个字都不许出给顾客。上游失败在 HAR 会分成**两类**抛出({@code BusinessException}
  *       = 上游明确拒绝,{@code HarApiException} = 网络/解析/通道未启用且 {@code create}
- *       不捕它),两类都要收住。</li>
+ *       不捕它),两类都要收住;</li>
+ *   <li><b>门店是按顾客 IP 的地区挑的</b>(2026-10-08,异地收款风控):解析 IP → 省 → 市 →
+ *       挑一家同地区门店 → 填进 {@code third_shop_code}。挑不出来**必须拒收**
+ *       (不打上游、不落单),给错地区比拒收更糟 —— 那正是这套东西要规避的风控。
+ *       本类里地区目录用**真家伙**(纯内存、读打进包里的快照),只有 IP 那一跳与门店查询
+ *       是 mock 的。</li>
  * </ol>
  *
  * <p>★ <b>本类覆盖不到、由别的用例守着的那半边</b>:{@code pay_har_checkout_link} 必须登记在
@@ -66,18 +78,47 @@ class HarCheckoutControllerTest {
     private static final Long TENANT = 42L;
     private static final String MCH = "M001";
 
+    /** 顾客 IP —— 既是被解析的对象,也是挑门店时的散列种子 */
+    private static final String IP = "114.114.114.114";
+
     @Mock private HarCheckoutLinkService linkService;
     @Mock private HarMerchantService merchantService;
     @Mock private HarOrderService orderService;
+    @Mock private HarIpRegionResolver ipRegionResolver;
+    @Mock private HarShopService shopService;
+
+    /** 真目录:纯内存 + 读打进包里的快照,mock 掉等于把整段省市匹配从这层摘走 */
+    private final HarRegionCatalog regionCatalog = new HarRegionCatalog();
 
     private HarCheckoutController controller;
 
     @BeforeEach
     void setUp() {
-        controller = new HarCheckoutController(linkService, merchantService, orderService);
+        controller = new HarCheckoutController(linkService, merchantService, orderService,
+                shopService, ipRegionResolver, regionCatalog);
         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
     void tearDown() {
         SecurityContextHolder.clearContext();
@@ -139,7 +180,7 @@ class HarCheckoutControllerTest {
         when(linkService.requireByToken("tok")).thenReturn(link());
 
         BusinessException ex = assertThrows(BusinessException.class,
-                () -> controller.create("tok", pay("0.99")));
+                () -> controller.create("tok", pay("0.99"), request()));
 
         assertEquals(400, ex.getCode());
         assertTrue(ex.getMessage().contains("不能低于 1 元"), "实际: " + ex.getMessage());
@@ -152,7 +193,7 @@ class HarCheckoutControllerTest {
         when(linkService.requireByToken("tok")).thenReturn(link());
 
         assertEquals(400, assertThrows(BusinessException.class,
-                () -> controller.create("tok", pay(null))).getCode());
+                () -> controller.create("tok", pay(null), request())).getCode());
         verifyNoInteractions(merchantService, orderService);
     }
 
@@ -162,7 +203,7 @@ class HarCheckoutControllerTest {
         when(linkService.requireByToken("tok")).thenReturn(link());
 
         assertEquals(400, assertThrows(BusinessException.class,
-                () -> controller.create("tok", null)).getCode());
+                () -> controller.create("tok", null, request())).getCode());
         verifyNoInteractions(merchantService, orderService);
     }
 
@@ -170,9 +211,10 @@ class HarCheckoutControllerTest {
     @Test
     void create_floorExactlyOneYuan_isAccepted() {
         when(linkService.requireByToken("tok")).thenReturn(link());
+        stubRegionAndShop();
         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 元整必须放行");
         ArgumentCaptor<HarOrderCreateDTO> captor = ArgumentCaptor.forClass(HarOrderCreateDTO.class);
@@ -183,9 +225,10 @@ class HarCheckoutControllerTest {
     @Test
     void create_assemblesTheOrderFromTheLinkRow_andDelegatesWithNullEnterpriseAndApiKey() {
         when(linkService.requireByToken("tok")).thenReturn(link());
+        stubRegionAndShop();
         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);
         verify(orderService).create(captor.capture(), isNull(), isNull());
@@ -210,24 +253,205 @@ class HarCheckoutControllerTest {
         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
     void create_setsTenantContextForEveryDbOperationAndRestoresItAfterwards() {
         when(linkService.requireByToken("tok")).thenReturn(link());
+        when(ipRegionResolver.lookup(IP)).thenReturn(Optional.of(new IpGeo("CN", "江苏省", "JS", "南京")));
 
         List<Long> seen = new ArrayList<>();
         when(merchantService.requireByTenantAndCode(TENANT, MCH)).thenAnswer(inv -> {
             seen.add(currentTenantId());
             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 -> {
             seen.add(currentTenantId());
             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 写错时另一条还在)");
         assertNull(SecurityContextHolder.getContext().getAuthentication(),
                 "请求结束后必须把上下文还原(这是容器的线程,不是我们自己的线程)");
@@ -246,7 +470,7 @@ class HarCheckoutControllerTest {
                 .thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: " + MCH));
 
         assertEquals(404, assertThrows(BusinessException.class,
-                () -> controller.create("tok", pay("5"))).getCode());
+                () -> controller.create("tok", pay("5"), request())).getCode());
         verifyNoInteractions(orderService);
         assertNull(SecurityContextHolder.getContext().getAuthentication(),
                 "抛出的那条路径也要还原上下文(finally)");
@@ -270,7 +494,7 @@ class HarCheckoutControllerTest {
                 .thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: " + MCH));
 
         BusinessException ex = assertThrows(BusinessException.class,
-                () -> controller.create("tok", pay("5")));
+                () -> controller.create("tok", pay("5"), request()));
 
         assertEquals(404, ex.getCode());
         assertEquals("收款方暂时无法收款,请稍后重试或联系收款方", ex.getMessage(),
@@ -283,10 +507,11 @@ class HarCheckoutControllerTest {
     @Test
     void create_upstreamRejects_returnsBusinessFailure_withoutUpstreamWording() {
         when(linkService.requireByToken("tok")).thenReturn(link());
+        stubRegionAndShop();
         when(orderService.create(any(), any(), any()))
                 .thenThrow(new BusinessException("下单失败: 上游返回 40004: 参数错误"));
 
-        Result<?> r = controller.create("tok", pay("5"));
+        Result<?> r = controller.create("tok", pay("5"), request());
 
         assertEquals(500, r.getCode());
         assertEquals("支付发起失败,请稍后重试", r.getMsg());
@@ -301,10 +526,11 @@ class HarCheckoutControllerTest {
     @Test
     void create_upstreamTransportFailure_returnsBusinessFailure_too() {
         when(linkService.requireByToken("tok")).thenReturn(link());
+        stubRegionAndShop();
         when(orderService.create(any(), any(), any()))
                 .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("支付发起失败,请稍后重试", r.getMsg());
@@ -313,9 +539,10 @@ class HarCheckoutControllerTest {
     @Test
     void create_blankPayUrl_returns500() {
         when(linkService.requireByToken("tok")).thenReturn(link());
+        stubRegionAndShop();
         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("支付发起失败,请稍后重试", r.getMsg());
@@ -324,9 +551,10 @@ class HarCheckoutControllerTest {
     @Test
     void create_success_returnsOrderNoPayUrlAndPrice() {
         when(linkService.requireByToken("tok")).thenReturn(link());
+        stubRegionAndShop();
         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());
         HarCheckoutOrderVO vo = (HarCheckoutOrderVO) r.getData();