Ver código fonte

feat(汇安融): 开放 API 下单支持直传地区码挑店 —— 不指定门店时由平台按 GB/T 2260 省市码挑一家

openapi 是服务器对服务器调用,请求 IP 是调用方机房的,做不了像收款链接那样按顾客
IP 挑店 ⇒ 让调用方直传顾客地区。order/create 新增选填 province_code / city_code:

- 显式 shop_code / third_shop_code 优先,地区码被忽略(现状不变)
- 都没给而给了任一地区码 ⇒ 平台按该地区挑一家,填进 third_shop_code(实体与上游
  请求两处),响应回显挑中的那家
- 省码对目录校验、市码选填但必须在该省内 —— 与门店创建同一句 400 文案
  (HarShopService 抽出 requireKnownProvince 共用,行为未变)
- 该市无店回退全省(与收款链接腿同一步梯);全省无可用店 ⇒ 400 拒收,
  先于落库与上游(不留单、不打上游、不发订单信号)
- 同城多店的稳定散列种子 = out_biz_no
- 新增 HarOrderCreateJsonContractTest:钉住 16 个对外键名(生产 SNAKE_CASE 量具)、
  snake_case 绑定、camelCase 不静默通过

本批测试:HarShopRegionPickTest 13→22、HarOrderServiceTest 54→61、新契约类 3 条;
RED 为编译错(pickForOrder/存取器/构造器均不存在);五点变异两向验证(城市被忽略 4 红、
显式优先失效 2 红、不拒收 3 红、种子换掉 4 红、上游发 dto 的 1 红;无害重构全绿),
还原后三文件 sha256 逐字节回基线;整包 1232/0/0/0 BUILD SUCCESS。
alphaH 6 dias atrás
pai
commit
cb153add53

+ 14 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderCreateDTO.java

@@ -70,6 +70,20 @@ public class HarOrderCreateDTO {
     /** 平台门店编号 */
     private String shopCode;
 
+    /**
+     * 顾客所在省(选填,2026-10-09)。**只在没有传任何门店编码时有作用**:给了就由平台
+     * 按地区挑一家门店发起交易(挑店规则见 {@code HarShopService#pickForOrder},与收款
+     * 链接腿同一套目录、同一句 400)。GB/T 2260 省级两位码,如 "32"(江苏省)、"11"(北京市)
+     * —— 与门店创建的 province_code 是**同一套码**(10.1 节)。
+     */
+    private String provinceCode;
+
+    /**
+     * 顾客所在市(选填,配 province_code 使用)。GB/T 2260 地级四位码,如 "3201"(南京市)。
+     * 必须属于 province_code;不传则按全省挑,传了但该市没有可用门店回退全省。
+     */
+    private String cityCode;
+
     /** 支付过期时间,2 到 60,单位以上游为准,本地原样透传 */
     private Integer payTimeout;
 

+ 28 - 7
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -77,6 +77,14 @@ public class HarOrderService {
 
     private final HarOrderMapper orderMapper;
     private final HarMerchantService merchantService;
+    /**
+     * 按地区挑店(开放 API 直传地区码时用,2026-10-09)。
+     *
+     * <p>【只在没传门店码时被问到】{@link #create} 里显式 {@code shop_code}/{@code third_shop_code}
+     * 优先;都不给而给了地区码才调 {@link HarShopService#pickForOrder}(挑不出来它抛 400,
+     * 本类不吞)。收款链接腿走的是同一个服务的 {@code pickForRegion}(那边地区来自顾客 IP)。
+     */
+    private final HarShopService shopService;
     private final HarClient harClient;
     /**
      * 订单信号发布(订单实时推送)。
@@ -406,6 +414,17 @@ public class HarOrderService {
             merchant = merchantService.requireByTenant(getCurrentTenantId());
         }
 
+        // 按地区挑店(开放 API:调用方直传 GB/T 2260 省/市码,2026-10-09)。规则:显式门店码优先;
+        // 都没给而给了任一地区码 ⇒ 平台替调用方挑(挑不出来 pickForOrder 直接抛 400)。
+        // 必须先于落库与上游 —— 与收款链接腿同一口径:挑不出来就拒,本地不留单、上游不打。
+        String thirdShopCode = dto.getThirdShopCode();
+        if (StrUtil.isBlank(dto.getShopCode()) && StrUtil.isBlank(thirdShopCode)
+                && (StrUtil.isNotBlank(dto.getProvinceCode()) || StrUtil.isNotBlank(dto.getCityCode()))) {
+            thirdShopCode = shopService.pickForOrder(
+                    merchant.getMerchantCode(), dto.getProvinceCode(), dto.getCityCode(),
+                    dto.getOutBizNo()).getThirdShopId();
+        }
+
         String orderNo = ORDER_NO_PREFIX + SnowflakeIdGenerator.nextIdStr();
         boolean scanMode = StrUtil.isNotBlank(dto.getAuthCode());
 
@@ -419,7 +438,7 @@ public class HarOrderService {
         entity.setApiKey(apiKey);
         entity.setMerchantCode(merchant.getMerchantCode());
         entity.setShopCode(dto.getShopCode());
-        entity.setThirdShopCode(dto.getThirdShopCode());
+        entity.setThirdShopCode(thirdShopCode);
         entity.setPrice(dto.getPrice());
         entity.setTitle(dto.getTitle());
         entity.setTradeType(dto.getTradeType());
@@ -440,8 +459,8 @@ public class HarOrderService {
         // 本地单已落库后才发起上游调用:上游超时/失败时本地仍有完整记录,
         // 且 next_query_time 已排入轮询,不会因网络问题凭空丢单。
         HarResponse response = scanMode
-                ? harClient.pay(buildPayRequest(dto, merchant, orderNo))
-                : harClient.precreate(buildPrecreateRequest(dto, merchant, orderNo));
+                ? harClient.pay(buildPayRequest(dto, merchant, orderNo, thirdShopCode))
+                : harClient.precreate(buildPrecreateRequest(dto, merchant, orderNo, thirdShopCode));
 
         if (!response.isSuccess()) {
             // 上游**明确拒绝**(参数错误之类)⇒ 这笔单在上游从来没被建出来。
@@ -685,7 +704,8 @@ public class HarOrderService {
 
     private HarPrecreateRequest buildPrecreateRequest(HarOrderCreateDTO dto,
                                                       HarMerchantEntity merchant,
-                                                      String orderNo) {
+                                                      String orderNo,
+                                                      String thirdShopCode) {
         HarPrecreateRequest req = new HarPrecreateRequest();
         req.setPrice(dto.getPrice().toPlainString());
         req.setTitle(dto.getTitle());
@@ -696,7 +716,7 @@ public class HarOrderService {
         req.setMerchantCode(merchant.getMerchantCode());
         req.setThirdOrderNo(orderNo);
         req.setShopCode(dto.getShopCode());
-        req.setThirdShopCode(dto.getThirdShopCode());
+        req.setThirdShopCode(thirdShopCode);
         req.setPayTimeout(dto.getPayTimeout());
         req.setExtraParams(dto.getExtraParams());
         req.setThirdNotifyUrl("https://qcsj88888.com/api/v1/payment/notify/har");
@@ -705,7 +725,8 @@ public class HarOrderService {
 
     private HarPayRequest buildPayRequest(HarOrderCreateDTO dto,
                                           HarMerchantEntity merchant,
-                                          String orderNo) {
+                                          String orderNo,
+                                          String thirdShopCode) {
         HarPayRequest req = new HarPayRequest();
         req.setPrice(dto.getPrice().toPlainString());
         req.setTitle(dto.getTitle());
@@ -717,7 +738,7 @@ public class HarOrderService {
         req.setMerchantCode(merchant.getMerchantCode());
         req.setThirdOrderNo(orderNo);
         req.setShopCode(dto.getShopCode());
-        req.setThirdShopCode(dto.getThirdShopCode());
+        req.setThirdShopCode(thirdShopCode);
         req.setPayTimeout(dto.getPayTimeout());
         req.setExtraParams(dto.getExtraParams());
         req.setThirdNotifyUrl("https://qcsj88888.com/api/v1/payment/notify/har");

+ 54 - 3
java/src/main/java/com/payment/platform/module/payment/har/service/HarShopService.java

@@ -197,6 +197,47 @@ public class HarShopService {
         return shops.get(idx);
     }
 
+    /**
+     * 下单按调用方**直传的地区码**挑店(开放 API 用,2026-10-09)。
+     *
+     * <p>与收款链接腿({@link #pickForRegion} 的调用方 {@code HarCheckoutController})的差别
+     * 只在**地区来源**:那边是顾客 IP 解析出的地名,这边是调用方给的 GB/T 2260 码。目的相同 ——
+     * 支付宝对异地收款有风控,交易要落在顾客所在地区的门店上。
+     *
+     * <p>校验与 {@link #requireKnownCity} 同一套(同一句 400 文案,两处必须同时改);
+     * 唯一的差别是**市码选填**:不给就按全省挑;给了但该市没有可用门店则回退全省
+     * ({@link #pickFromCandidates} 的既有阶梯,与收款链接腿一致)。
+     *
+     * @param seed 同城多店的稳定散列种子(开放 API 传订单的 out_biz_no;空白 ⇒ 固定取第一家)
+     * @throws BusinessException 400 —— 省码/市码不认识、传了市码没传省码、或该地区没有可用门店
+     */
+    public HarShopEntity pickForOrder(String merchantCode, String provinceCode,
+                                      String cityCode, String seed) {
+        String ccRaw = cityCode == null ? "" : cityCode.trim();
+        if (provinceCode == null || provinceCode.trim().isEmpty()) {
+            throw new BusinessException(400, ccRaw.isEmpty()
+                    ? "省份编码不能为空"
+                    : "省份编码不能为空: 传了 city_code 就必须一并传 province_code");
+        }
+        String pc = requireKnownProvince(provinceCode);
+        String cc = null;
+        if (!ccRaw.isEmpty()) {
+            cc = regionCatalog.cityByCode(pc, ccRaw)
+                    .orElseThrow(() -> new BusinessException(400,
+                            "城市编码不属于该省: " + cityCode + "(省 " + provinceCode + ")"))
+                    .code();
+        }
+        HarShopEntity shop = pickForRegion(merchantCode, pc, cc, seed);
+        // 挑不到、或挑中的店没有 third_shop_id ⇒ 拒。守卫同结账腿(HarCheckoutController 里那两行):
+        // 静默退回「不带门店下单」等于调用方明确要求的地区归属悄悄落空。
+        if (shop == null || StrUtil.isBlank(shop.getThirdShopId())) {
+            throw new BusinessException(400, cc == null
+                    ? "该地区没有可用门店: 省 " + pc
+                    : "该地区没有可用门店: " + cc + "(省 " + pc + ")");
+        }
+        return shop;
+    }
+
     @Transactional(rollbackFor = Exception.class)
     public HarShopVO create(HarShopCreateDTO dto, String enterpriseId) {
         // 地区编码**放在最前面**对目录。理由:这两列写错的门店在收款链接里永远挑不中
@@ -298,14 +339,24 @@ public class HarShopService {
      * @return 目录里那条地级记录,调用方用它的 code 落库/发上游(即**规范化后的**编码)
      */
     private HarRegionCatalog.City requireKnownCity(String provinceCode, String cityCode) {
+        String pc = requireKnownProvince(provinceCode);
+        return regionCatalog.cityByCode(pc, cityCode)
+                .orElseThrow(() -> new BusinessException(400,
+                        "城市编码不属于该省: " + cityCode + "(省 " + provinceCode + ")"));
+    }
+
+    /**
+     * 省码必须认识(对目录),返回**规范化后**(trim 过)的省码。门店创建
+     * ({@link #requireKnownCity})与下单挑店({@link #pickForOrder})共用,
+     * 两处的 400 文案因此必然是同一句 —— 开放文档 10.1/10.2 引用的是它。
+     */
+    private String requireKnownProvince(String provinceCode) {
         String pc = provinceCode == null ? "" : provinceCode.trim();
         boolean provinceKnown = regionCatalog.provinces().stream().anyMatch(p -> pc.equals(p.code()));
         if (!provinceKnown) {
             throw new BusinessException(400, "省份编码不存在: " + provinceCode);
         }
-        return regionCatalog.cityByCode(pc, cityCode)
-                .orElseThrow(() -> new BusinessException(400,
-                        "城市编码不属于该省: " + cityCode + "(省 " + provinceCode + ")"));
+        return pc;
     }
 
     /**

+ 78 - 0
java/src/test/java/com/payment/platform/module/payment/har/dto/HarOrderCreateJsonContractTest.java

@@ -0,0 +1,78 @@
+package com.payment.platform.module.payment.har.dto;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException;
+import com.payment.platform.common.config.JacksonConfig;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * 钉住《汇安融开放 API 文档》10.2 下单接口的**请求字段名**。
+ *
+ * <p>为什么值得一个测试:{@link JacksonConfig} 把全局 ObjectMapper 设成了
+ * {@code PropertyNamingStrategies.SNAKE_CASE},所以 DTO 上的 {@code provinceCode}
+ * 在线上收发的是 {@code province_code}。**照 Java 字段名写文档就会全篇写错**,
+ * 而错法很隐蔽 —— 接入方拿 camelCase 请求体调过来,签名字符串跟着算错,
+ * 报出来的错误容易让人去查签名而不是字段名。
+ *
+ * <p>2026-10-09 新增「按地区挑门店」({@code province_code}/{@code city_code})时补上本类:
+ * 这两个字段第一次作为**开放 API 的入参契约**存在(10.1 建店那两个是老契约),
+ * 文档、DTO、上游请求三处必须钉在同一组键名上。
+ *
+ * <p>本类的断言**用的就是生产那个 ObjectMapper**({@link JacksonConfig#objectMapper()}),
+ * 不是新建一个默认的 —— 默认 ObjectMapper 是 camelCase,钉不住任何东西。
+ */
+class HarOrderCreateJsonContractTest {
+
+    /** 与容器里那个 @Primary bean 同一个构造方式。 */
+    private final ObjectMapper mapper = new JacksonConfig().objectMapper();
+
+    private Set<String> keysOf(Object bean) {
+        try {
+            return new TreeSet<>(mapper.readValue(mapper.writeValueAsString(bean), Map.class).keySet());
+        } catch (Exception e) {
+            throw new IllegalStateException("序列化失败: " + bean.getClass().getSimpleName(), e);
+        }
+    }
+
+    /** 对外键集 —— 与开放文档 10.2 请求表的每一行逐一对得上(含两个地区码) */
+    @Test
+    void orderCreateRequestFields() {
+        HarOrderCreateDTO d = new HarOrderCreateDTO();
+        d.setProvinceCode("32");
+        d.setCityCode("3201");
+
+        assertEquals(Set.of("enterprise_id", "merchant_code", "out_biz_no", "price", "title",
+                        "trade_type", "biz", "stage_num", "percent", "third_shop_code", "shop_code",
+                        "pay_timeout", "extra_params", "auth_code", "province_code", "city_code"),
+                keysOf(d));
+    }
+
+    /** 调用方按文档传 {@code province_code} / {@code city_code} ⇒ 必须真的绑进 DTO */
+    @Test
+    void regionFieldsBindFromTheSnakeCaseNamesTheDocQuotes() throws Exception {
+        HarOrderCreateDTO d = mapper.readValue(
+                "{\"province_code\":\"32\",\"city_code\":\"3201\"}", HarOrderCreateDTO.class);
+
+        assertEquals("32", d.getProvinceCode());
+        assertEquals("3201", d.getCityCode());
+    }
+
+    /**
+     * 反方向:写成 camelCase(照直觉)**不会静默通过** —— 生产那支是裸
+     * {@code new ObjectMapper()},FAIL_ON_UNKNOWN_PROPERTIES 为 true,绑不上直接报错。
+     * 「静默忽略」比报错危险:调用方以为挑店生效了,实际订单没带地区归属,
+     * 直接落到异地收款风控上。
+     */
+    @Test
+    void camelCaseNamesDoNotBindSilently() {
+        assertThrows(UnrecognizedPropertyException.class, () -> mapper.readValue(
+                "{\"provinceCode\":\"32\"}", HarOrderCreateDTO.class));
+    }
+}

+ 138 - 1
java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

@@ -19,6 +19,7 @@ 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.HarMerchantEntity;
 import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.entity.HarShopEntity;
 import com.payment.platform.module.payment.har.enums.HarOrderStatus;
 import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
 import com.payment.platform.module.payment.stream.OrderEventPublisher;
@@ -58,6 +59,7 @@ class HarOrderServiceTest {
 
     @Mock private HarOrderMapper orderMapper;
     @Mock private HarMerchantService merchantService;
+    @Mock private HarShopService shopService;
     @Mock private HarClient harClient;
     @Mock private OrderEventPublisher publisher;
 
@@ -65,7 +67,7 @@ class HarOrderServiceTest {
 
     @BeforeEach
     void setUp() {
-        service = new HarOrderService(orderMapper, merchantService, harClient, publisher);
+        service = new HarOrderService(orderMapper, merchantService, shopService, harClient, publisher);
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
         // (否则 getSqlSegment() 抛 MybatisPlusException: can not find lambda cache for this entity。
         //   同款引导见 LsyCallbackServiceTest / AlipayBatchPayServiceTest。)
@@ -327,6 +329,141 @@ class HarOrderServiceTest {
         verify(harClient, never()).precreate(any());
     }
 
+    // ==================== 下单:按地区挑店(开放 API 直传地区码,2026-10-09) ====================
+
+    /**
+     * 传了省/市码、没传任何门店码 ⇒ 平台替调用方挑店;挑中的店要**同时**进本地记录
+     * 与上游请求 —— 只进一处都是半截:本地有、上游没有 = 风控不认;上游有、本地没有 =
+     * 页面上看不出这单挂的哪家店。
+     */
+    @Test
+    void create_withRegionCodes_picksShopAndFillsThirdShopCode() {
+        HarOrderCreateDTO dto = dto();
+        dto.setProvinceCode("32");
+        dto.setCityCode("3201");
+        HarShopEntity picked = new HarShopEntity();
+        picked.setThirdShopId("NJ");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        // 精确参数 = 同时钉住散列种子就是 out_biz_no(种子若被换掉,这条会因参数不匹配拿到 null 而红)
+        when(shopService.pickForOrder("M001", "32", "3201", "BIZ-0001")).thenReturn(picked);
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarOrderEntity> entity = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(entity.capture());
+        assertEquals("NJ", entity.getValue().getThirdShopCode(), "挑中的店要进本地记录");
+        assertNull(entity.getValue().getShopCode(), "只填 third_shop_code(与收款链接腿一致),不编造平台门店码");
+
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertEquals("NJ", req.getValue().getThirdShopCode(), "挑中的店必须真的发到上游");
+    }
+
+    /** 被扫腿同款接线:builder 改了一个、漏了另一个的话,这条红而上面那条绿 */
+    @Test
+    void create_scanMode_alsoSendsThePickedShop() {
+        HarOrderCreateDTO dto = dto();
+        dto.setAuthCode("280812345678901234");
+        dto.setProvinceCode("32");
+        HarShopEntity picked = new HarShopEntity();
+        picked.setThirdShopId("NJ");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopService.pickForOrder("M001", "32", null, "BIZ-0001")).thenReturn(picked);
+        when(harClient.pay(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarPayRequest> req = ArgumentCaptor.forClass(HarPayRequest.class);
+        verify(harClient).pay(req.capture());
+        assertEquals("NJ", req.getValue().getThirdShopCode(), "挑中的店必须真的发到上游");
+    }
+
+    /** 显式门店码优先:给了 third_shop_code 就不再挑(挑出来的会覆盖调用方明确指定的店) */
+    @Test
+    void create_explicitThirdShopCode_beatsRegionCodes() {
+        HarOrderCreateDTO dto = dto();
+        dto.setThirdShopCode("EXPLICIT");
+        dto.setProvinceCode("32");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        verifyNoInteractions(shopService);
+        ArgumentCaptor<HarOrderEntity> entity = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(entity.capture());
+        assertEquals("EXPLICIT", entity.getValue().getThirdShopCode());
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertEquals("EXPLICIT", req.getValue().getThirdShopCode(), "上游发的必须是显式指定的那个,不是挑的");
+    }
+
+    /** 显式门店码优先:给了 shop_code 同样不挑 */
+    @Test
+    void create_explicitShopCode_beatsRegionCodes() {
+        HarOrderCreateDTO dto = dto();
+        dto.setShopCode("S-PLATFORM");
+        dto.setProvinceCode("32");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        verifyNoInteractions(shopService);
+        ArgumentCaptor<HarOrderEntity> entity = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(entity.capture());
+        assertEquals("S-PLATFORM", entity.getValue().getShopCode());
+        assertNull(entity.getValue().getThirdShopCode(), "没挑店就不该凭空多出一个 third_shop_code");
+    }
+
+    /**
+     * 挑不出来 ⇒ 400,且**不留本地单、不打上游、不发订单信号** —— 与收款链接腿
+     * 「挑不出来就拒收」同一口径。静默退回「不带门店下单」等于调用方明确要求的
+     * 地区归属被悄悄丢掉。
+     */
+    @Test
+    void create_regionPickRejected_400_beforePersistAndUpstream() {
+        HarOrderCreateDTO dto = dto();
+        dto.setProvinceCode("32");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopService.pickForOrder("M001", "32", null, "BIZ-0001"))
+                .thenThrow(new BusinessException(400, "该地区没有可用门店: 省 32"));
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.create(dto, "E1", "KEY1"));
+
+        assertEquals(400, e.getCode());
+        verify(orderMapper, never()).insert(any());
+        verify(harClient, never()).precreate(any());
+        verify(publisher, never()).changed(any(), any());
+    }
+
+    /** 什么都不传 = 现状:一个字段都不碰,不去问挑店 */
+    @Test
+    void create_noRegionFields_neverConsultsShopService() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto(), "E1", "KEY1");
+
+        verifyNoInteractions(shopService);
+    }
+
+    /** 只给 city_code 也是「地区意图」——进入挑店并吃它那句 400,而不是被静默忽略 */
+    @Test
+    void create_cityCodeWithoutProvince_entersPickAndPropagates400() {
+        HarOrderCreateDTO dto = dto();
+        dto.setCityCode("3201");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopService.pickForOrder("M001", null, "3201", "BIZ-0001"))
+                .thenThrow(new BusinessException(400, "省份编码不能为空: 传了 city_code 就必须一并传 province_code"));
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.create(dto, "E1", "KEY1"));
+
+        assertEquals(400, e.getCode());
+        verify(orderMapper, never()).insert(any());
+    }
+
     // ==================== 主动查询 ====================
 
     /**

+ 102 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarShopRegionPickTest.java

@@ -3,6 +3,7 @@ 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.common.exception.BusinessException;
 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;
@@ -24,6 +25,7 @@ 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.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.verify;
@@ -239,4 +241,104 @@ class HarShopRegionPickTest {
 
         assertEquals("NJ", service.pickForRegion("M001", "32", "3201", "114.114.114.114").getThirdShopId());
     }
+
+    // ──────────────────────────── 下单按调用方直传的地区码挑店(开放 API,2026-10-09) ────────────────────────────
+
+    /**
+     * 与收款链接腿的差别只在**地区来源**:那边是顾客 IP 解析出的地名,这边是调用方
+     * 直接给的 GB/T 2260 码。阶梯本身上面各条已钉住,这里钉「码怎么校验、挑不出来怎么办」。
+     */
+    @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(), "不给市码 ⇒ 按全省挑,异省的店绝不越界");
+    }
+
+    @Test
+    void pickForOrder_cityCode_reachesTheCityTier() {
+        when(shopMapper.selectList(any())).thenReturn(List.of(
+                ok("NJ", "32", "3201"),
+                ok("SZ", "32", "3205")));
+
+        assertEquals("NJ", service.pickForOrder("M001", "32", "3201", "BIZ-1").getThirdShopId());
+    }
+
+    /** 给了市码但该市没有可用门店 ⇒ 回退全省挑(与收款链接腿同一步梯),不是报错 */
+    @Test
+    void pickForOrder_cityMissing_fallsBackToTheSameProvince() {
+        when(shopMapper.selectList(any())).thenReturn(List.of(ok("SZ", "32", "3205")));
+
+        assertEquals("SZ", service.pickForOrder("M001", "32", "3201", "BIZ-1").getThirdShopId());
+    }
+
+    @Test
+    void pickForOrder_unknownProvinceCode_400_sameMessageAsShopCreate() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", "99", null, "BIZ-1"));
+
+        assertEquals(400, e.getCode());
+        assertEquals("省份编码不存在: 99", e.getMessage(),
+                "与门店创建那条必须一字不差 —— 开放文档 10.1/10.2 引用的是同一句话");
+        verify(shopMapper, org.mockito.Mockito.never()).selectList(any());
+    }
+
+    @Test
+    void pickForOrder_cityNotInThatProvince_400_sameMessageAsShopCreate() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", "32", "3301", "BIZ-1"));
+
+        assertEquals(400, e.getCode());
+        assertEquals("城市编码不属于该省: 3301(省 32)", e.getMessage());
+    }
+
+    /** 只给了市码没给省码:是调用方的输入错误,响亮拒绝(省码是地区定位的根) */
+    @Test
+    void pickForOrder_cityWithoutProvince_400() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", null, "3201", "BIZ-1"));
+
+        assertEquals(400, e.getCode());
+        assertEquals("省份编码不能为空: 传了 city_code 就必须一并传 province_code", e.getMessage());
+    }
+
+    @Test
+    void pickForOrder_noUsableShopInProvince_400_namesTheProvince() {
+        when(shopMapper.selectList(any())).thenReturn(List.of(ok("HZ", "33", "3301")));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", "32", null, "BIZ-1"));
+
+        assertEquals("该地区没有可用门店: 省 32", e.getMessage());
+    }
+
+    @Test
+    void pickForOrder_noUsableShopWithCity_400_namesTheCity() {
+        when(shopMapper.selectList(any())).thenReturn(List.of());
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", "32", "3201", "BIZ-1"));
+
+        assertEquals("该地区没有可用门店: 3201(省 32)", e.getMessage());
+    }
+
+    /**
+     * 挑中了但 third_shop_id 为空 ⇒ 与「没挑到」同一条 400。结账腿有同样的守卫
+     * ({@code pickShopForCustomer} 那两行);不守的话会拿一个空编码去下单,
+     * 调用方以为挑店生效了,实际风控归属是空的。
+     */
+    @Test
+    void pickForOrder_pickedShopWithoutThirdShopId_400() {
+        when(shopMapper.selectList(any())).thenReturn(List.of(
+                shop(null, "32", "3201", HarShopCreateStatus.SUCCESS, "S-PLATFORM")));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", "32", "3201", "BIZ-1"));
+
+        assertEquals("该地区没有可用门店: 3201(省 32)", e.getMessage());
+    }
 }