Forráskód Böngészése

feat(汇安融): 开放 API 挑店改版 —— 传顾客 IP 或省码(IP 优先、省码回退),不再收市码

- HarOrderCreateDTO:删 city_code、加 ip;province_code 改为「按省挑店」
- pickForOrder:IP 能解析 ⇒ 以它为准(精确到市,该市无店回退全省);解析不出 ⇒ 用省码;
  只传 IP 且到头无店 ⇒ 400 不静默;seed 为空 ⇒ 随机挑(传了 out_biz_no 仍稳定落同一家)
- create:无门店码但给了 ip/province_code ⇒ 平台替调用方挑店
- 开放文档 10.2 写清地区格式与三种 400 文案;误传 city_code 现在响亮失败(UnrecognizedPropertyException)
alphaH 6 napja
szülő
commit
94d120d132

+ 10 - 7
frontend/src/views/module_payment/apikey/docs/har-openapi.md

@@ -30,7 +30,7 @@
 
 **门店与下单的关系**:10.2 里的 `shop_code` / `third_shop_code` 都是**选填**。门店不是下单的前置条件 —— 只有需要把交易归属到某个具体门店时才填。
 
-> **但门店的「地区」是有用的**:平台的收款链接(第 6 章的顾客付款页)下单时会按顾客 IP 的归属地自动挑一家同地区的门店,见 10.1 的地区说明。你的接口自己下单时若指定了门店,那个地区的风控判断同样适用;或者**不指定门店、改传顾客地区的编码**(`province_code` / `city_code`,2026-10-09 起),由平台在该地区替你挑一家 —— 见 10.2。
+> **但门店的「地区」是有用的**:平台的收款链接(第 6 章的顾客付款页)下单时会按顾客 IP 的归属地自动挑一家同地区的门店,见 10.1 的地区说明。你的接口自己下单时若指定了门店,那个地区的风控判断同样适用;或者**不指定门店、改传顾客归属**(顾客 IP 或省级编码,2026-10-09 起),由平台在该地区替你挑一家 —— 见 10.2。
 
 ---
 
@@ -113,17 +113,19 @@ POST /payment/openapi/har/order/create
 | `percent` | string | 否 | 分期手续费承担方:`100` 商户承担 / `0` 用户承担。`trade_type=alipay_stage` 时必填 |
 | `shop_code` | string | 否 | 平台门店编号(10.1 返回的 `shop_id`) |
 | `third_shop_code` | string | 否 | 你方门店编码,≤32 字符 |
-| `province_code` | string | 否 | **不指定门店时**由平台按地区挑店:顾客所在省,GB/T 2260 两位码(`"32"` 江苏省、`"11"` 北京市),与 10.1 建店**同一套码**,见下 |
-| `city_code` | string | 否 | 顾客所在市,GB/T 2260 四位码(`"3201"` 南京市),必须属于 `province_code`;不传则按全省挑 |
+| `ip` | string | 否 | **不指定门店时**由平台按地区挑店:**顾客的公网 IP**(不是服务器的),精度到市,**优先于 `province_code`**,见下 |
+| `province_code` | string | 否 | 顾客所在省,GB/T 2260 两位码(`"32"` 江苏省、`"11"` 北京市),与 10.1 建店**同一套码**;`ip` 解析不出(或 IP 地区没有可用门店)时的回退,见下 |
 | `pay_timeout` | int | 否 | 支付过期时间,取值 2–60(单位由上游定义,平台原样透传) |
 | `extra_params` | string | 否 | 透传字段 |
 
-> **按地区自动挑店(2026-10-09 起,选填)**:不传 `shop_code` / `third_shop_code`、但传了 `province_code` 时,平台**替你在该地区挑一家门店**去发起交易,与平台的收款链接(第 6 章顾客付款页)同一套规则、同一个地区目录。
+> **按地区自动挑店(2026-10-09 起,选填)**:不传 `shop_code` / `third_shop_code`、但传了 `ip` 或 `province_code` 时,平台**替你在顾客所在地区挑一家门店**去发起交易,与平台的收款链接(第 6 章顾客付款页)同一套规则、同一个地区目录。`ip` 与 `province_code` 可以只传一个,也可以都传。
 >
-> - **格式**(照这个填,填错会挑不到门店):GB/T 2260 行政区划代码,与 10.1 建店**完全同一套** —— 省级两位、地级四位,传的是**代码不是地名**(写 `"32"`,写成 `"江苏省"` 会被拒);`city_code` 必须属于所传的 `province_code`。
-> - **怎么挑**:先在同市可用门店里挑;该市没有则回退到全省挑(不报错);同地区有多家时按 `out_biz_no` 稳定挑一家(同一 `out_biz_no` 重复调用落同一家)。
+> - **优先级**:`ip` 能解析出归属地时**以 `ip` 为准**(精确到市);解析不出(内网地址、非大陆 IP、查询失败)或 IP 地区没有可用门店时,**回退用 `province_code` 按全省挑**。两个都传最稳 —— IP 不灵时省码兜底。
+> - **`ip` 填什么**:**顾客发起支付的公网 IP**,不是你服务器的出口 IP、也不是你后台的 IP。格式照常(`"114.114.114.114"`)。
+> - **`province_code` 格式**(照这个填,填错会挑不到门店):GB/T 2260 行政区划代码,与 10.1 建店**完全同一套** —— 省级两位,传的是**代码不是地名**(写 `"32"`,写成 `"江苏省"` 会被拒)。
+> - **怎么挑**:先在同市可用门店里挑(市由 `ip` 解析决定);该市没有则回退到全省挑(不报错);同地区有多家时按 `out_biz_no` 稳定挑一家(同一 `out_biz_no` 重复调用落同一家;没传 `out_biz_no` 时随机挑一家)。
 > - **挑中哪家**:响应里的 `third_shop_code` 就是挑中的那家(本来该你传的字段,现在由平台回显)。
-> - **挑不出来会被拒收**(`code=400`,这单不会建立、不会扣款):码不认识 —— `省份编码不存在: 99` / `城市编码不属于该省: 3301(省 32)`;只传 `city_code` 没传 `province_code` —— `省份编码不能为空: 传了 city_code 就必须一并传 province_code`;该地区没有可用门店 —— `该地区没有可用门店: 省 32`(带市码时为 `该地区没有可用门店: 3201(省 32)`)。「可用门店」= 10.1 建号成功(`create_status=SUCCESS`)且地区与所传编码一致的门店;2026-10-08 之前建的门店没有地区,不会被挑中(见 10.1 的警告)。
+> - **挑不出来会被拒收**(`code=400`,这单不会建立、不会扣款):省码不认识 —— `省份编码不存在: 99`;只传 `ip` 而 IP 解析不出 —— `用户 IP 无法解析出归属地: 1.2.3.4`;IP 地区与省码都挑不到 —— `该地区没有可用门店: 省 32`(`ip` 解析到了市时形如 `该地区没有可用门店: 4301(省 43)`)。「可用门店」= 10.1 建号成功(`create_status=SUCCESS`)且地区与所传编码一致的门店;2026-10-08 之前建的门店没有地区,不会被挑中(见 10.1 的警告)。
 > - **传了任一门店编码时,这两个字段被忽略**(显式指定优先,平台不会用挑出来的店覆盖你指定的店)。
 
 **响应**(B 型,`data` 内)
@@ -332,6 +334,7 @@ Content-Type: application/x-www-form-urlencoded
 
 - [ ] 认证与签名复用 3.3 的实现,先跑通 3.4 的样例
 - [ ] 建号带了 `province_code` / `city_code`(GB/T 2260),且市码属于该省码
+- [ ] 不指定门店下单时传了 `ip`(**顾客**的公网 IP)或 `province_code`,且拿响应里的 `third_shop_code` 核对了挑中的门店
 - [ ] `price` 用**元**、写成最简小数(不要 `1.50`、`0.010`)
 - [ ] 下单**不重发** —— 超时先查 10.3,确认没有再决定
 - [ ] `auth_code` 有值走被扫、不传走主扫,两种都测过

+ 11 - 6
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderCreateDTO.java

@@ -72,17 +72,22 @@ public class HarOrderCreateDTO {
 
     /**
      * 顾客所在省(选填,2026-10-09)。**只在没有传任何门店编码时有作用**:给了就由平台
-     * 按地区挑一家门店发起交易(挑店规则见 {@code HarShopService#pickForOrder},与收款
-     * 链接腿同一套目录、同一句 400)。GB/T 2260 省级两位码,如 "32"(江苏省)、"11"(北京市)
-     * —— 与门店创建的 province_code 是**同一套码**(10.1 节)。
+     * 按地区挑一家门店发起交易(挑店规则见 {@code HarShopService#pickForOrder})。
+     * GB/T 2260 省级两位码,如 "32"(江苏省)、"11"(北京市)—— 与门店创建的
+     * province_code 是**同一套码**(10.1 节)。
+     *
+     * <p>与 {@code ip} 二选一或同传:IP 能解析出归属地时**以 IP 为准**(更精确);
+     * IP 解析不出、或 IP 归属地没有可用门店时回退到本字段按全省挑。
      */
     private String provinceCode;
 
     /**
-     * 顾客所在市(选填,配 province_code 使用)。GB/T 2260 地级四位码,如 "3201"(南京市)。
-     * 必须属于 province_code;不传则按全省挑,传了但该市没有可用门店回退全省。
+     * 顾客的公网 IP(选填,2026-10-09)。★ 传的是**顾客的** IP,不是服务器出口 IP。
+     *
+     * <p>平台用它解析顾客归属地、挑对应地区的门店交易(支付宝对异地收款有风控),
+     * 精度到市,优先于 province_code。可单独传、也可与 province_code 同传作为回退。
      */
-    private String cityCode;
+    private String ip;
 
     /** 支付过期时间,2 到 60,单位以上游为准,本地原样透传 */
     private Integer payTimeout;

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

@@ -78,11 +78,12 @@ public class HarOrderService {
     private final HarOrderMapper orderMapper;
     private final HarMerchantService merchantService;
     /**
-     * 按地区挑店(开放 API 直传地区码时用,2026-10-09)。
+     * 按地区挑店(开放 API 传顾客 IP / 省码时用,2026-10-09)。
      *
      * <p>【只在没传门店码时被问到】{@link #create} 里显式 {@code shop_code}/{@code third_shop_code}
-     * 优先;都不给而给了地区码才调 {@link HarShopService#pickForOrder}(挑不出来它抛 400,
-     * 本类不吞)。收款链接腿走的是同一个服务的 {@code pickForRegion}(那边地区来自顾客 IP)。
+     * 优先;都不给而给了 {@code ip}/{@code province_code} 才调 {@link HarShopService#pickForOrder}
+     * (IP 优先、省码回退;挑不出来它抛 400,本类不吞)。收款链接腿走的是同一个服务的
+     * {@code pickForRegion}(那边地区来自顾客 IP)。
      */
     private final HarShopService shopService;
     private final HarClient harClient;
@@ -414,14 +415,15 @@ public class HarOrderService {
             merchant = merchantService.requireByTenant(getCurrentTenantId());
         }
 
-        // 按地区挑店(开放 API:调用方直传 GB/T 2260 省/市码,2026-10-09)。规则:显式门店码优先;
-        // 都没给而给了任一地区码 ⇒ 平台替调用方挑(挑不出来 pickForOrder 直接抛 400)。
+        // 按顾客归属挑店(开放 API:调用方传顾客 IP / GB/T 2260 省码,2026-10-09)。
+        // 规则:显式门店码优先;都没给而给了 IP 或省码 ⇒ 平台替调用方挑
+        // (IP 优先、省码回退;挑不出来 pickForOrder 直接抛 400)。
         // 必须先于落库与上游 —— 与收款链接腿同一口径:挑不出来就拒,本地不留单、上游不打。
         String thirdShopCode = dto.getThirdShopCode();
         if (StrUtil.isBlank(dto.getShopCode()) && StrUtil.isBlank(thirdShopCode)
-                && (StrUtil.isNotBlank(dto.getProvinceCode()) || StrUtil.isNotBlank(dto.getCityCode()))) {
+                && (StrUtil.isNotBlank(dto.getIp()) || StrUtil.isNotBlank(dto.getProvinceCode()))) {
             thirdShopCode = shopService.pickForOrder(
-                    merchant.getMerchantCode(), dto.getProvinceCode(), dto.getCityCode(),
+                    merchant.getMerchantCode(), dto.getProvinceCode(), dto.getIp(),
                     dto.getOutBizNo()).getThirdShopId();
         }
 

+ 67 - 30
java/src/main/java/com/payment/platform/module/payment/har/service/HarShopService.java

@@ -28,6 +28,8 @@ import org.springframework.transaction.annotation.Transactional;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ThreadLocalRandom;
 
 /**
  * 门店服务。
@@ -49,6 +51,8 @@ public class HarShopService {
     private final HarMerchantService merchantService;
     private final HarClient harClient;
     private final HarRegionCatalog regionCatalog;
+    /** 顾客 IP → 归属地(开放 API 下单可传顾客 IP,2026-10-09)。同包,收款链接腿用的是同一个 */
+    private final HarIpRegionResolver ipRegionResolver;
 
     public PageResult<HarShopVO> list(int pageNo, int pageSize, HarShopQueryDTO q) {
         LambdaQueryWrapper<HarShopEntity> w = new LambdaQueryWrapper<>();
@@ -198,44 +202,77 @@ public class HarShopService {
     }
 
     /**
-     * 下单按调用方**直传的地区码**挑店(开放 API 用,2026-10-09)。
+     * 下单按**顾客归属**挑店(开放 API 用,2026-10-09 改版:IP 优先、省码回退)。
      *
-     * <p>与收款链接腿({@link #pickForRegion} 的调用方 {@code HarCheckoutController})的差别
-     * 只在**地区来源**:那边是顾客 IP 解析出的地名,这边是调用方给的 GB/T 2260 码。目的相同 ——
-     * 支付宝对异地收款有风控,交易要落在顾客所在地区的门店上。
+     * <p>与收款链接腿({@link #pickForRegion} 的调用方 {@code HarCheckoutController})目的相同 ——
+     * 支付宝对异地收款有风控,交易要落在顾客所在地区的门店上。差别在**地区来源的定序**:
+     * 调用方可以传顾客公网 IP(精确到市)、GB/T 2260 省码(只到省),或两者都传:
      *
-     * <p>校验与 {@link #requireKnownCity} 同一套(同一句 400 文案,两处必须同时改);
-     * 唯一的差别是**市码选填**:不给就按全省挑;给了但该市没有可用门店则回退全省
-     * ({@link #pickFromCandidates} 的既有阶梯,与收款链接腿一致)。
+     * <ol>
+     *   <li>IP 能解析、且解析出的省在目录里 ⇒ 按 IP 归属挑(市名对上就按市,对不上退该省
+     *       —— 上游 IP 库常给区级名);IP 归属地没有可用门店 ⇒ 回退 province_code 继续挑
+     *       (2026-10-09 用户裁定:IP 侧任何失败都回退,不留「IP 不灵就整体拒收」的硬缺口);</li>
+     *   <li>IP 缺失 / 解析不出 / 省名不认识 ⇒ 直接用 province_code 按全省挑;</li>
+     *   <li>两条路都没结果 ⇒ 400 拒收(本地不留单、上游不打)。</li>
+     * </ol>
+     *
+     * <p>只传 IP 而 IP 侧到不了头时**不静默**:报 400 —— 地区意图是调用方明确表达过的,
+     * 悄悄落空等于把订单交给异地收款风控。
      *
-     * @param seed 同城多店的稳定散列种子(开放 API 传订单的 out_biz_no;空白 ⇒ 固定取第一家)
-     * @throws BusinessException 400 —— 省码/市码不认识、传了市码没传省码、或该地区没有可用门店
+     * @param ip   顾客公网 IP,可空
+     * @param seed 同地区多店的稳定散列种子(开放 API 传订单的 out_biz_no);
+     *             **空白 ⇒ 现掷随机种子** —— 连单号都没给时,「每次都落第一家」是最差的分布
+     * @throws BusinessException 400 —— 省码不认识、或两条路都没挑到带 third_shop_id 的可用门店
      */
     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 ip, String seed) {
+        String effectiveSeed = StrUtil.isBlank(seed)
+                ? Long.toString(ThreadLocalRandom.current().nextLong())
+                : seed;
+
+        if (StrUtil.isNotBlank(ip)) {
+            Optional<HarIpRegionResolver.IpGeo> geo = ipRegionResolver.lookup(ip);
+            if (geo.isPresent()) {
+                Optional<HarRegionCatalog.Province> province =
+                        regionCatalog.matchProvince(geo.get().regionName());
+                if (province.isPresent()) {
+                    String pc = province.get().code();
+                    String cc = regionCatalog.matchCity(pc, geo.get().cityName())
+                            .map(HarRegionCatalog.City::code)
+                            .orElse(null);
+                    HarShopEntity picked = pickForRegion(merchantCode, pc, cc, effectiveSeed);
+                    if (picked != null && StrUtil.isNotBlank(picked.getThirdShopId())) {
+                        return picked;
+                    }
+                    if (StrUtil.isBlank(provinceCode)) {
+                        throw new BusinessException(400, cc == null
+                                ? "该地区没有可用门店: 省 " + pc
+                                : "该地区没有可用门店: " + cc + "(省 " + pc + ")");
+                    }
+                    // 回退日志不带 IP —— IP 是个人信息,解析器自己那份日志已经掩码
+                    // (口径同 HarCheckoutController#pickShopForCustomer 的 javadoc)。
+                    log.info("[挑店] IP 归属地无可用门店,回退省份码: ip省={}, ip市={}, provinceCode={}",
+                            pc, cc == null ? "-" : cc, provinceCode);
+                }
+            }
+            // 解析失败 / 省名不认识 / IP 地区没店但给了省码 ⇒ 全部落到下面 province_code 这条
         }
-        String pc = requireKnownProvince(provinceCode);
-        String cc = null;
-        if (!ccRaw.isEmpty()) {
-            cc = regionCatalog.cityByCode(pc, ccRaw)
-                    .orElseThrow(() -> new BusinessException(400,
-                            "城市编码不属于该省: " + cityCode + "(省 " + provinceCode + ")"))
-                    .code();
+
+        if (StrUtil.isNotBlank(provinceCode)) {
+            String pc = requireKnownProvince(provinceCode);
+            HarShopEntity shop = pickForRegion(merchantCode, pc, null, effectiveSeed);
+            // 挑不到、或挑中的店没有 third_shop_id ⇒ 拒。守卫同结账腿(HarCheckoutController 里那两行):
+            // 静默退回「不带门店下单」等于调用方明确要求的地区归属悄悄落空。
+            if (shop == null || StrUtil.isBlank(shop.getThirdShopId())) {
+                throw new BusinessException(400, "该地区没有可用门店: 省 " + pc);
+            }
+            return shop;
         }
-        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 + ")");
+
+        if (StrUtil.isNotBlank(ip)) {
+            throw new BusinessException(400, "用户 IP 无法解析出归属地: " + ip);
         }
-        return shop;
+        throw new BusinessException(400, "用户 IP 与省份编码至少填一个");
     }
 
     @Transactional(rollbackFor = Exception.class)

+ 19 - 8
java/src/test/java/com/payment/platform/module/payment/har/dto/HarOrderCreateJsonContractTest.java

@@ -21,9 +21,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
  * 而错法很隐蔽 —— 接入方拿 camelCase 请求体调过来,签名字符串跟着算错,
  * 报出来的错误容易让人去查签名而不是字段名。
  *
- * <p>2026-10-09 新增「按地区挑门店」({@code province_code}/{@code city_code})时补上本类:
+ * <p>2026-10-09 新增「按顾客归属挑门店」({@code ip} / {@code province_code})时补上本类:
  * 这两个字段第一次作为**开放 API 的入参契约**存在(10.1 建店那两个是老契约),
- * 文档、DTO、上游请求三处必须钉在同一组键名上。
+ * 文档、DTO、上游请求三处必须钉在同一组键名上。同日改版:不再收 {@code city_code}
+ * (挑市只由 {@code ip} 解析决定),误传该键会响亮失败 —— 见 {@code removedCityCodeField}。
  *
  * <p>本类的断言**用的就是生产那个 ObjectMapper**({@link JacksonConfig#objectMapper()}),
  * 不是新建一个默认的 —— 默认 ObjectMapper 是 camelCase,钉不住任何东西。
@@ -41,27 +42,27 @@ class HarOrderCreateJsonContractTest {
         }
     }
 
-    /** 对外键集 —— 与开放文档 10.2 请求表的每一行逐一对得上(含两个地区码) */
+    /** 对外键集 —— 与开放文档 10.2 请求表的每一行逐一对得上(IP 与省码两个地区入参) */
     @Test
     void orderCreateRequestFields() {
         HarOrderCreateDTO d = new HarOrderCreateDTO();
         d.setProvinceCode("32");
-        d.setCityCode("3201");
+        d.setIp("114.114.114.114");
 
         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"),
+                        "pay_timeout", "extra_params", "auth_code", "province_code", "ip"),
                 keysOf(d));
     }
 
-    /** 调用方按文档传 {@code province_code} / {@code city_code} ⇒ 必须真的绑进 DTO */
+    /** 调用方按文档传 {@code ip} / {@code province_code} ⇒ 必须真的绑进 DTO */
     @Test
     void regionFieldsBindFromTheSnakeCaseNamesTheDocQuotes() throws Exception {
         HarOrderCreateDTO d = mapper.readValue(
-                "{\"province_code\":\"32\",\"city_code\":\"3201\"}", HarOrderCreateDTO.class);
+                "{\"province_code\":\"32\",\"ip\":\"114.114.114.114\"}", HarOrderCreateDTO.class);
 
         assertEquals("32", d.getProvinceCode());
-        assertEquals("3201", d.getCityCode());
+        assertEquals("114.114.114.114", d.getIp());
     }
 
     /**
@@ -75,4 +76,14 @@ class HarOrderCreateJsonContractTest {
         assertThrows(UnrecognizedPropertyException.class, () -> mapper.readValue(
                 "{\"provinceCode\":\"32\"}", HarOrderCreateDTO.class));
     }
+
+    /**
+     * {@code city_code} 是 2026-10-09 改版前的旧键,从未发布出去;现在传它必须**响亮失败**
+     * 而不是被静默忽略 —— 静默忽略等于调用方以为指定了市、实际只按省挑,落点换了都不知道。
+     */
+    @Test
+    void removedCityCodeField_failsLoudly() {
+        assertThrows(UnrecognizedPropertyException.class, () -> mapper.readValue(
+                "{\"province_code\":\"32\",\"city_code\":\"3201\"}", HarOrderCreateDTO.class));
+    }
 }

+ 13 - 11
java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

@@ -329,23 +329,25 @@ class HarOrderServiceTest {
         verify(harClient, never()).precreate(any());
     }
 
-    // ==================== 下单:按地区挑店(开放 API 直传地区码,2026-10-09) ====================
+    // ==================== 下单:按顾客归属挑店(开放 API:IP 优先、省码回退,2026-10-09) ====================
 
     /**
-     * 传了省/市码、没传任何门店码 ⇒ 平台替调用方挑店;挑中的店要**同时**进本地记录
+     * 传了 IP/省码、没传任何门店码 ⇒ 平台替调用方挑店;挑中的店要**同时**进本地记录
      * 与上游请求 —— 只进一处都是半截:本地有、上游没有 = 风控不认;上游有、本地没有 =
      * 页面上看不出这单挂的哪家店。
+     *
+     * <p>精确参数 = 同时钉住「第三个位置传的是 ip、种子是 out_biz_no」——两个参数
+     * 任一被换错,这条会因参数不匹配拿到 null 而红。
      */
     @Test
-    void create_withRegionCodes_picksShopAndFillsThirdShopCode() {
+    void create_withIpAndProvinceCode_passesBothToThePick() {
         HarOrderCreateDTO dto = dto();
         dto.setProvinceCode("32");
-        dto.setCityCode("3201");
+        dto.setIp("114.114.114.114");
         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(shopService.pickForOrder("M001", "32", "114.114.114.114", "BIZ-0001")).thenReturn(picked);
         when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
 
         service.create(dto, "E1", "KEY1");
@@ -449,14 +451,14 @@ class HarOrderServiceTest {
         verifyNoInteractions(shopService);
     }
 
-    /** 只给 city_code 也是「地区意图」——进入挑店并吃它那句 400,而不是被静默忽略 */
+    /** 只给 ip 也是「地区意图」——进入挑店并吃它那句 400,而不是被静默忽略 */
     @Test
-    void create_cityCodeWithoutProvince_entersPickAndPropagates400() {
+    void create_ipOnly_entersPickAndPropagates400() {
         HarOrderCreateDTO dto = dto();
-        dto.setCityCode("3201");
+        dto.setIp("1.2.3.4");
         when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
-        when(shopService.pickForOrder("M001", null, "3201", "BIZ-0001"))
-                .thenThrow(new BusinessException(400, "省份编码不能为空: 传了 city_code 就必须一并传 province_code"));
+        when(shopService.pickForOrder("M001", null, "1.2.3.4", "BIZ-0001"))
+                .thenThrow(new BusinessException(400, "用户 IP 无法解析出归属地: 1.2.3.4"));
 
         BusinessException e = assertThrows(BusinessException.class, () -> service.create(dto, "E1", "KEY1"));
 

+ 131 - 39
java/src/test/java/com/payment/platform/module/payment/har/service/HarShopRegionPickTest.java

@@ -20,6 +20,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -44,12 +45,13 @@ class HarShopRegionPickTest {
     @Mock private HarShopMapper shopMapper;
     @Mock private HarMerchantService merchantService;
     @Mock private HarClient harClient;
+    @Mock private HarIpRegionResolver ipRegionResolver;
 
     private HarShopService service;
 
     @BeforeEach
     void setUp() {
-        service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog());
+        service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog(), ipRegionResolver);
         // 同 HarShopServiceTest:让 LambdaQueryWrapper.getSqlSegment() 能在无 Spring 上下文时工作
         MybatisConfiguration configuration = new MybatisConfiguration();
         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());
     }
 
-    // ──────────────────────────── 下单按调用方直传的地区码挑店(开放 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
-    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(
-                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
-    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(
-                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
-    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
-    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,
-                () -> service.pickForOrder("M001", "99", null, "BIZ-1"));
+                () -> service.pickForOrder("M001", null, "1.2.3.4", "BIZ-1"));
 
         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());
     }
 
+    /** 只传 ip、解析出了地区但一家店都没有:报的是**那个地区**,不是「解析不出」 */
     @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,
-                () -> 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
-    void pickForOrder_cityWithoutProvince_400() {
+    void pickForOrder_neitherIpNorProvince_400() {
         BusinessException e = assertThrows(BusinessException.class,
-                () -> service.pickForOrder("M001", null, "3201", "BIZ-1"));
+                () -> service.pickForOrder("M001", null, null, "BIZ-1"));
 
         assertEquals(400, e.getCode());
-        assertEquals("省份编码不能为空: 传了 city_code 就必须一并传 province_code", e.getMessage());
     }
 
+    /** 只传省份、省内多店、没传 out_biz_no ⇒ 随机种子,仍要散开(不能退化成固定第一家) */
     @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,
-                () -> 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
-    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,
-                () -> 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")));
 
         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());
     }
 }

+ 3 - 1
java/src/test/java/com/payment/platform/module/payment/har/service/HarShopServiceTest.java

@@ -39,6 +39,8 @@ class HarShopServiceTest {
     @Mock private HarShopMapper shopMapper;
     @Mock private HarMerchantService merchantService;
     @Mock private HarClient harClient;
+    /** 本类只测门店创建;挑店那套在 HarShopRegionPickTest,这里只需要一个不炸的替身 */
+    @Mock private HarIpRegionResolver ipRegionResolver;
 
     private HarShopService service;
 
@@ -46,7 +48,7 @@ class HarShopServiceTest {
     void setUp() {
         // 地区目录用**真家伙**(纯内存、无依赖,读的是打进包里的快照):
         // 门店创建时它要校验省市编码,mock 掉就等于把这道校验从每条 create 用例里摘掉了。
-        service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog());
+        service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog(), ipRegionResolver);
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
         // (否则 getSqlSegment() 抛 MybatisPlusException: can not find lambda cache for this entity。
         //   同款引导见 LsyCallbackServiceTest / AlipayBatchPayServiceTest。)