Selaa lähdekoodia

feat(汇安融): 门店地区与按 IP 挑店(一)—— 目录快照 + IP 归属地解析 + 挑店阶梯

门店按地区路由的前半段(收款链接结账腿的接线在下一刀):

- 省市目录 HarRegionCatalog:打包快照 /har-region-cn.json(31 省级 / 342 地级,
  含 166 条繁简对应),按名匹配 + 按码校验 + 对称归一化;
- IP 归属地 HarIpRegionResolver:ipwho.is(免 key;?lang=zh-CN 是硬前提,不带它
  回的是英文/拼音,按中文名匹配全线失效),成功/失败两档缓存(6h / 60s,都是有界
  LRU)—— 失败那档必须短,否则上游抖一下会把「拒收」钉死一批顾客一小时;
- 门店带 province_code / city_code:创建时对表校验(省要认识、市要属于该省)并把
  编码**规范化**后再落库/发上游。上游没有门店查询/更新接口,编码写错的门店在挑店时
  永远挑不中,且全程没有任何报错,只能在建的那一刻拦住;
- HarShopService#pickForRegion:同城 → 同省 → 拒收 的阶梯,按 seed(顾客 IP)稳定
  散列选一家,只认 SUCCESS 且 shop_id 非空的门店(PENDING 的语义是「结果未知」)。

验证:HarRegionCatalogTest 14、HarIpRegionResolverTest 15、HarShopRegionPickTest 13、
HarShopServiceTest 18 全绿。
alphaH 1 viikko sitten
vanhempi
sitoutus
b1c8f6fb51

+ 13 - 0
java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarShopCreateRequest.java

@@ -17,4 +17,17 @@ public class HarShopCreateRequest {
     private String thirdShopId;
 
     private String name;
+
+    /**
+     * 省份编码,GB/T 2260 两位({@code 32} = 江苏省)。**上游必填**(规范 5.5,2026-10-08 起)。
+     *
+     * <p>字段名照规范写死:漏了 {@code @JsonProperty} 就会发成 {@code provinceCode},
+     * 上游只会回一句参数错误,本地从字段上看不出来 —— 有测试盯报文原文。
+     */
+    @JsonProperty("province_code")
+    private String provinceCode;
+
+    /** 市编码,GB/T 2260 四位({@code 3201} = 南京市)。**上游必填**。见 {@link #provinceCode} */
+    @JsonProperty("city_code")
+    private String cityCode;
 }

+ 12 - 0
java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java

@@ -44,6 +44,18 @@ public class HarProperties {
      */
     private String checkoutBaseUrl;
 
+    /**
+     * IP 归属地服务的根地址(收款链接按顾客 IP 选同地区门店用),**以 / 结尾**。
+     *
+     * <p>ipwho.is:免 key 的 HTTPS 服务。**代码里带默认值 ⇒ 不配也能跑**,与
+     * {@code checkoutBaseUrl} 同一条口径;线上若出不去外网,把它指到内网代理即可。
+     * 注意这条**不受 {@code har.enabled} 管**:门店表单的省市下拉也要用它,与上游调用无关。
+     */
+    private String ipRegionUrl = "https://ipwho.is/";
+
+    /** IP 归属地查询超时(毫秒)。顾客在前台等着,宁可判空拒收也不要长挂 */
+    private int ipRegionTimeoutMs = 3000;
+
     /** 异步回调企业商户的线程数上限 */
     private int callbackPoolSize = 4;
 

+ 16 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopCreateDTO.java

@@ -39,4 +39,20 @@ public class HarShopCreateDTO {
 
     /** 选填。为空 ⇒ 走**租户级**商户映射(enterprise_id IS NULL 的那一行) */
     private String enterpriseId;
+
+    /**
+     * 省份编码,GB/T 2260 两位({@code 32} = 江苏省)。**必填**(2026-10-08 规范 5.5 起)。
+     *
+     * <p>取值必须是 {@code HarRegionCatalog} 里有的码 —— 服务层会对表校验,
+     * 因为编码写错的门店在收款链接里**永远挑不中**,而上游没有门店更新接口,事后补不上。
+     * 前端下拉直接吃目录({@code /payment/har/region/provinces}),别自己存一份省市表。
+     */
+    @NotBlank(message = "省份不能为空")
+    @Size(max = 16, message = "省份编码最长 16 位")
+    private String provinceCode;
+
+    /** 市编码,GB/T 2260 四位({@code 3201} = 南京市)。**必填**,且必须属于上面那个省。 */
+    @NotBlank(message = "城市不能为空")
+    @Size(max = 16, message = "城市编码最长 16 位")
+    private String cityCode;
 }

+ 6 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopVO.java

@@ -17,6 +17,10 @@ public class HarShopVO {
     private String merchantCode;
     private String thirdShopId;
     private String shopName;
+    /** GB/T 2260 两位省码;2026-10-08 之前建的门店为空 */
+    private String provinceCode;
+    /** GB/T 2260 四位市码;与 {@link #provinceCode} 同在 */
+    private String cityCode;
     private String shopId;
     private String createStatus;
     private String failReason;
@@ -35,6 +39,8 @@ public class HarShopVO {
         vo.setMerchantCode(e.getMerchantCode());
         vo.setThirdShopId(e.getThirdShopId());
         vo.setShopName(e.getShopName());
+        vo.setProvinceCode(e.getProvinceCode());
+        vo.setCityCode(e.getCityCode());
         vo.setShopId(e.getShopId());
         vo.setCreateStatus(e.getCreateStatus());
         vo.setFailReason(e.getFailReason());

+ 15 - 0
java/src/main/java/com/payment/platform/module/payment/har/entity/HarShopEntity.java

@@ -24,6 +24,21 @@ public class HarShopEntity extends PaymentEnterpriseBaseEntity {
 
     private String shopName;
 
+    /**
+     * 所属省份编码,GB/T 2260 两位({@code 32} = 江苏省)。
+     *
+     * <p>上游 {@code kf.har.shop.create} 自 2026-10-08 规范起把 province_code / city_code
+     * 列为**必填**,且支付宝对异地收款有风控 —— 收款链接下单时要按顾客 IP 挑同地区门店,
+     * 就是靠这两列与 {@link com.payment.platform.module.payment.har.util.HarRegionCatalog} 对上。
+     *
+     * <p>★ 上游**没有**门店查询/更新接口(见类 javadoc)⇒ 2026-10-08 之前建的门店**补不上**这两个值,
+     * 它们在挑选时会被当成「地区不匹配」而落选。这是已知且刻意的:宁可拒收,不可错配地区。
+     */
+    private String provinceCode;
+
+    /** 所属地级编码,GB/T 2260 四位({@code 3201} = 南京市)。见 {@link #provinceCode} */
+    private String cityCode;
+
     /** 汇安融返回的门店 ID,未成功时为空 */
     private String shopId;
 

+ 230 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarIpRegionResolver.java

@@ -0,0 +1,230 @@
+package com.payment.platform.module.payment.har.service;
+
+import cn.hutool.cache.CacheUtil;
+import cn.hutool.cache.impl.LRUCache;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.http.HttpUtil;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.module.payment.har.config.HarProperties;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+
+/**
+ * 请求者 IP → 归属地(省 / 市)。
+ *
+ * <h2>它为什么存在</h2>
+ * 支付宝对异地收款有风控(2026-10-08 用户口径)⇒ 收款链接下单时要按顾客 IP 挑一家**同地区**
+ * 的门店。这个类负责「IP → 中文省名/市名」这一段,**只做搬运**:上游给什么名字就交什么名字,
+ * 归一化与匹配归 {@code HarRegionCatalog}(那份数据集在它手里)。
+ *
+ * <h2>上游与实测口径(2026-10-08 逐条 curl 量过)</h2>
+ * <ul>
+ *   <li>ipwho.is,免 key HTTPS(备选 ip-api.com 免费版只有明文 HTTP,故弃用 —— 同
+ *       {@code credit-pay} 的 {@code IpLocationService} 的取舍);</li>
+ *   <li><b>{@code ?lang=zh-CN} 是硬前提</b>:不带它 {@code region}/{@code city} 回的是英文/拼音,
+ *       按中文名匹配全线失效({@code ?lang=zh}、{@code ?lang=cn}、{@code ?lang=zh_CN} 都**不**本地化,
+ *       这是实测结论,别改);</li>
+ *   <li>判断「是不是大陆」用 {@code country_code == "CN"}({@code country} 是中文「中国」,
+ *       但代码更稳);港澳台在这个字段上就不是 CN,天然被挡在门外;</li>
+ *   <li>名字**不做任何规整**:实测拿到过繁体({@code 渝中區}、{@code 西藏自治區})与区级名
+ *       ({@code 渝中區} 出现在 {@code city} 里)—— 交给目录那条归一化去处理;</li>
+ *   <li>失败形态有两种真实回包:{@code {"success":false,"message":"Reserved range"}} 与
+ *       HTTP 404({@code {"success":false,"message":"404 not found"}},hutool 不会因为 4xx 抛异常,
+ *       所以这里要自己看 {@code success})。</li>
+ * </ul>
+ *
+ * <h2>判空 = 下游拒收</h2>
+ * 这个方法返回空有**四种**来路:内网/空白地址(不打上游)、取数失败、上游说没解析出来、
+ * 非中国大陆。它们对顾客是同一件事 —— 收款链接会拒收这笔支付(**不打上游、不落单**),
+ * 对操作员则在日志里是四种不同的字。
+ *
+ * <h2>缓存:两档,失败那档**必须短**</h2>
+ * 成功结果按 IP 缓存(容量 {@value #HIT_CACHE_CAPACITY}、TTL 6 小时 —— 归属地几乎不变);
+ * 失败/非 CN 走另一档(容量 {@value #MISS_CACHE_CAPACITY}、TTL 60 秒)。★ 失败若跟着成功
+ * 用同一个长 TTL,上游抖一下就会把「拒收」按小时钉死一批顾客;短 TTL 让它在 60 秒内自愈。
+ * 两档都**封顶**(有界 LRU,不是无界 Map):IP 是外部输入,不封顶就是一条堆耗尽路径。
+ *
+ * <p>★ 本类**不吃 {@code har.enabled}**:那条开关管的是「要不要打汇安融上游」,
+ * 而这里打的是第三方的 IP 库,关掉 har 不代表顾客不该被正确路由。
+ */
+@Slf4j
+@Service
+public class HarIpRegionResolver {
+
+    /**
+     * 解析结果。字段就是上游那几个,**未经归一化**。
+     *
+     * @param countryCode 上游 {@code country_code},只放行 {@code CN}
+     * @param regionName  省名原文(可能带后缀、可能是繁体)
+     * @param regionCode  上游 ISO 3166-2 省码(如 {@code JS})—— **仅供日志**,本方案不按它匹配
+     * @param cityName    市名原文(可能是区级名、可能繁体、可能没有后缀)
+     */
+    public record IpGeo(String countryCode, String regionName, String regionCode, String cityName) {
+    }
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    /*
+     * 下面两个 TTL 与容量所依赖的 hutool 语义,是 2026-10-08 用 logs/_probe_hutool_cache.java
+     * 与 logs/_probe2_hutool_expiry.java 在**本项目实际用的 hutool-all 5.8.34** 上量出来的
+     * (5.8.25 读数相同),不是照文档或记忆写的:
+     *   ① newLRUCache(cap, 200) 睡 400ms 后 get 回 null ⇒ **超时单位是毫秒**;
+     *   ② 过期后 containsKey(该 key 上首次访问)回 false ⇒ **containsKey 认过期**,
+     *      所以负缓存可以直接用它判,不必先 get 一次;
+     *   ③ 正控:ttl=60s 的同形状对象 400ms 后 containsKey=true ⇒ ②不是量具坏了;
+     *   ④ capacity=3 放 5 条后 size=3、最早那条被逐 ⇒ **逐出真的发生**,封顶不是纸面的。
+     */
+
+    private static final int HIT_CACHE_CAPACITY = 4096;
+    private static final long HIT_CACHE_TTL_MS = 6 * 60 * 60 * 1000L;
+    private static final int MISS_CACHE_CAPACITY = 512;
+    private static final long MISS_CACHE_TTL_MS = 60 * 1000L;
+
+    private static final String MAINLAND = "CN";
+
+    /** 内网/保留段前缀:问了也只会得到 "Reserved range",白等一个 RTT,还平白把内网地址发出去 */
+    private static final List<String> PRIVATE_PREFIXES = List.of(
+            "127.", "10.", "192.168.", "169.254.", "0.",
+            "::1", "0:0:0:0:0:0:0:1", "fe80:", "fc00:", "fd00:");
+
+    private final HarProperties properties;
+    private final Function<String, String> fetcher;
+    private final LRUCache<String, IpGeo> hitCache;
+    private final LRUCache<String, Boolean> missCache;
+
+    @Autowired
+    public HarIpRegionResolver(HarProperties properties) {
+        this(properties, url -> HttpUtil.get(url, properties.getIpRegionTimeoutMs()));
+    }
+
+    /**
+     * 取数那一跳的接缝:测试塞回包用。
+     *
+     * <p>两个构造器(本包内可见的那个是给测试的)⇒ 必须显式 {@code @Autowired},
+     * 否则 Spring 在有多个构造器且无默认构造器时不知道该用哪个。
+     */
+    HarIpRegionResolver(HarProperties properties, Function<String, String> fetcher) {
+        this(properties, fetcher, HIT_CACHE_TTL_MS, MISS_CACHE_TTL_MS);
+    }
+
+    /**
+     * TTL 可注入:只为让测试能**证明「两档真的是两档」**({@code lookup_negativeCacheExpires
+     * QuicklySoItSelfHeals} 及其正控)。生产路径永远走上面那两个常量。
+     */
+    HarIpRegionResolver(HarProperties properties, Function<String, String> fetcher,
+                        long hitTtlMs, long missTtlMs) {
+        this.properties = properties;
+        this.fetcher = fetcher;
+        this.hitCache = CacheUtil.newLRUCache(HIT_CACHE_CAPACITY, hitTtlMs);
+        this.missCache = CacheUtil.newLRUCache(MISS_CACHE_CAPACITY, missTtlMs);
+    }
+
+    /** @return 空 ⇒ 下游据此**拒收**(原因见类 javadoc) */
+    public Optional<IpGeo> lookup(String ip) {
+        if (isUnresolvable(ip)) {
+            return Optional.empty();
+        }
+        String key = ip.trim();
+
+        IpGeo hit = hitCache.get(key);
+        if (hit != null) {
+            return Optional.of(hit);
+        }
+        if (missCache.containsKey(key)) {
+            return Optional.empty();
+        }
+
+        Optional<IpGeo> geo = fetch(key);
+        if (geo.isPresent()) {
+            hitCache.put(key, geo.get());
+        } else {
+            missCache.put(key, Boolean.TRUE);
+        }
+        return geo;
+    }
+
+    private Optional<IpGeo> fetch(String ip) {
+        String base = properties.getIpRegionUrl();
+        if (!base.endsWith("/")) {
+            base = base + "/";
+        }
+        String url = base + ip + "?lang=zh-CN";
+        String body;
+        try {
+            body = fetcher.apply(url);
+        } catch (Exception e) {
+            log.warn("[汇安融IP归属地] 取数失败 ip={} 原因={}", mask(ip), e.getMessage());
+            return Optional.empty();
+        }
+
+        try {
+            JsonNode root = MAPPER.readTree(body);
+            if (!root.path("success").asBoolean(false)) {
+                log.warn("[汇安融IP归属地] 上游未解析出结果 ip={} msg={}",
+                        mask(ip), root.path("message").asText(""));
+                return Optional.empty();
+            }
+            String countryCode = root.path("country_code").asText("");
+            if (!MAINLAND.equals(countryCode)) {
+                log.info("[汇安融IP归属地] 非中国大陆 ip={} country={}", mask(ip), countryCode);
+                return Optional.empty();
+            }
+            return Optional.of(new IpGeo(countryCode,
+                    root.path("region").asText(""),
+                    root.path("region_code").asText(""),
+                    root.path("city").asText("")));
+        } catch (Exception e) {
+            log.warn("[汇安融IP归属地] 回包解析失败 ip={} 原因={}", mask(ip), e.getMessage());
+            return Optional.empty();
+        }
+    }
+
+    private static boolean isUnresolvable(String ip) {
+        if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip.trim())) {
+            return true;
+        }
+        String v = ip.trim();
+        for (String prefix : PRIVATE_PREFIXES) {
+            if (v.startsWith(prefix)) {
+                return true;
+            }
+        }
+        // 172.16.0.0/12 是唯一一段不能靠前缀判的内网(172.16 ~ 172.31)
+        if (v.startsWith("172.")) {
+            String[] parts = v.split("\\.");
+            if (parts.length == 4) {
+                try {
+                    int second = Integer.parseInt(parts[1]);
+                    return second >= 16 && second <= 31;
+                } catch (NumberFormatException ignored) {
+                    return false;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 日志里只留前两段:IP 是个人信息,而排障要的是「哪个地区/哪一类地址」,
+     * 不需要完整地址。(掩码只用于日志,缓存与请求仍用原值。)
+     */
+    private static String mask(String ip) {
+        if (ip == null) {
+            return "";
+        }
+        int lastDot = ip.lastIndexOf('.');
+        if (lastDot > 0) {
+            String head = ip.substring(0, lastDot);
+            int secondDot = head.lastIndexOf('.');
+            return (secondDot > 0 ? head.substring(0, secondDot) : head) + ".*.*";
+        }
+        int lastColon = ip.lastIndexOf(':');
+        return lastColon > 0 ? ip.substring(0, lastColon) + ":*" : ip;
+    }
+}

+ 127 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarShopService.java

@@ -19,12 +19,14 @@ import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
 import com.payment.platform.module.payment.har.entity.HarShopEntity;
 import com.payment.platform.module.payment.har.enums.HarShopCreateStatus;
 import com.payment.platform.module.payment.har.mapper.HarShopMapper;
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -46,6 +48,7 @@ public class HarShopService {
     private final HarShopMapper shopMapper;
     private final HarMerchantService merchantService;
     private final HarClient harClient;
+    private final HarRegionCatalog regionCatalog;
 
     public PageResult<HarShopVO> list(int pageNo, int pageSize, HarShopQueryDTO q) {
         LambdaQueryWrapper<HarShopEntity> w = new LambdaQueryWrapper<>();
@@ -100,8 +103,108 @@ public class HarShopService {
         return entity;
     }
 
+    /**
+     * 按地区挑一家可用于收款的门店(收款链接下单时用)。
+     *
+     * <h2>为什么需要它</h2>
+     * 支付宝对**异地收款**有风控(2026-10-08 用户口径)⇒ 顾客扫码下单时,要用**顾客 IP 所在
+     * 地区**的门店去`precreate`,否则容易被风控拦下。
+     *
+     * <h2>阶梯(每一级挑不出来就往下退,退到底就拒收)</h2>
+     * <ol>
+     *   <li>同城(省码 + 城市码都对上)≥1 家 ⇒ 在这一组里挑;</li>
+     *   <li>该城市无店 ⇒ 退到同省 ≥1 家 ⇒ 在这一组里挑;</li>
+     *   <li>该省一家都没有 ⇒ {@code null},调用方据此**拒收**(不打上游、不落单)。</li>
+     * </ol>
+     * ★ 退到底宁可拒收也不「随便给一家」:给错地区正是这套东西要规避的风控,
+     * 而拒收对顾客只是一句可读的失败。
+     *
+     * <h2>什么算「能用的店」</h2>
+     * 只有 {@link HarShopCreateStatus#SUCCESS} 且 {@code shop_id} 非空的店 —— PENDING 的语义是
+     * 「结果未知」(见类 javadoc),FAILED 是上游明确拒绝,两者拿去下单必然失败。
+     * <b>并且地区字段必须对上</b>:2026-10-08 之前建的门店没有这两列(上游**没有**门店查询/
+     * 更新接口,补不上),它们会因地区不匹配而落选 —— 这是刻意的,不是漏判。
+     *
+     * <h2>同地区多店怎么挑</h2>
+     * 按 {@code seed}(调用方传顾客 IP)做**稳定散列**:同一个顾客永远落到同一家
+     * (缓存过期后重来也一样),不同顾客散开,避免全压在一家上。
+     *
+     * @param merchantCode 上游商户号;门店挂在上游商户下,跨商户的 shop_id 拿去下单必被拒
+     * @param provinceCode 顾客 IP 归属的省码({@code HarRegionCatalog} 给的)
+     * @param cityCode     顾客 IP 归属的城市码,可为空(上游偶尔只给到省)
+     * @param seed         散列种子,传顾客 IP
+     * @return 挑中的门店;挑不出来 {@code null}
+     */
+    public HarShopEntity pickForRegion(String merchantCode, String provinceCode, String cityCode, String seed) {
+        // 空商户号不去查库:`merchant_code = ''` 只会查出一片空,然后静默拒收,
+        // 与「这个商户底下真没有可用的店」在日志里长得一样 —— 分不开就查不出问题。
+        if (StrUtil.isBlank(merchantCode) || StrUtil.isBlank(provinceCode)) {
+            return null;
+        }
+        List<HarShopEntity> candidates = shopMapper.selectList(new LambdaQueryWrapper<HarShopEntity>()
+                .eq(HarShopEntity::getMerchantCode, merchantCode));
+        return pickFromCandidates(candidates, provinceCode, cityCode, seed);
+    }
+
+    /**
+     * 阶梯本体:**纯函数**,不碰库(查询只负责把该商户的门店捞出来,可用性判定全在这里)。
+     * 包内可见是为了让测试能直接喂候选集 —— 这一段是整条链上最容易写错、也最该被钉死的部分。
+     */
+    static HarShopEntity pickFromCandidates(List<HarShopEntity> candidates, String provinceCode,
+                                            String cityCode, String seed) {
+        if (candidates == null || StrUtil.isBlank(provinceCode)) {
+            return null;
+        }
+        List<HarShopEntity> usable = new ArrayList<>();
+        for (HarShopEntity c : candidates) {
+            if (c == null
+                    || !HarShopCreateStatus.SUCCESS.equals(c.getCreateStatus())
+                    || StrUtil.isBlank(c.getShopId())
+                    || !provinceCode.equals(c.getProvinceCode())) {
+                continue;
+            }
+            usable.add(c);
+        }
+        if (usable.isEmpty()) {
+            return null;
+        }
+        if (StrUtil.isNotBlank(cityCode)) {
+            List<HarShopEntity> inCity = new ArrayList<>();
+            for (HarShopEntity c : usable) {
+                if (cityCode.equals(c.getCityCode())) {
+                    inCity.add(c);
+                }
+            }
+            if (!inCity.isEmpty()) {
+                return spread(inCity, seed);
+            }
+        }
+        return spread(usable, seed);
+    }
+
+    /**
+     * 在同一组门店里按 {@code seed} 稳定挑一家。
+     *
+     * <p>用 {@code String.hashCode()} 而不是真随机:它由 JLS 定死,跨 JVM、跨重启都一样 ——
+     * 同一顾客重试时落到同一家,这才是「稳定散列」而不是「每次掷骰子」。
+     * 种子空白时归 0(不用 {@code "  ".hashCode()}),免得空格串与 null 落到不同的店。
+     */
+    private static HarShopEntity spread(List<HarShopEntity> shops, String seed) {
+        if (shops.size() == 1) {
+            return shops.get(0);
+        }
+        int idx = Math.floorMod(StrUtil.isBlank(seed) ? 0 : seed.hashCode(), shops.size());
+        return shops.get(idx);
+    }
+
     @Transactional(rollbackFor = Exception.class)
     public HarShopVO create(HarShopCreateDTO dto, String enterpriseId) {
+        // 地区编码**放在最前面**对目录。理由:这两列写错的门店在收款链接里永远挑不中
+        // (挑选读的正是 province_code/city_code,见 pickForRegion),而上游没有门店查询/
+        // 更新接口 ⇒ 建出来就补不上,全程没有任何报错。宁可在这里给运营一句能读懂的话。
+        // 顺带把编码**规范化**成目录里的那个值再往下传,避免 " 32 " 这种原样落库、
+        // 挑店时却比不上的静默失配。
+        HarRegionCatalog.City city = requireKnownCity(dto.getProvinceCode(), dto.getCityCode());
         // 企业为空 ⇒ 按**租户**寻址(去企业化,2026-09-24,见 db/har-tenant-level.sql)。
         // 分流规则与 HarOpenController#requireMerchant、HarOrderService#create
         // 是同一套 —— 三处必须同时改。
@@ -128,6 +231,8 @@ public class HarShopService {
         req.setMerchantCode(merchant.getMerchantCode());
         req.setThirdShopId(dto.getThirdShopId());
         req.setName(dto.getShopName());
+        req.setProvinceCode(city.provinceCode());
+        req.setCityCode(city.code());
 
         // 1. 先落 PENDING —— 无论后续发生什么都留下记录
         HarShopEntity entity = new HarShopEntity();
@@ -138,6 +243,8 @@ public class HarShopService {
         entity.setMerchantCode(merchant.getMerchantCode());
         entity.setThirdShopId(dto.getThirdShopId());
         entity.setShopName(dto.getShopName());
+        entity.setProvinceCode(city.provinceCode());
+        entity.setCityCode(city.code());
         entity.setCreateStatus(HarShopCreateStatus.PENDING);
         entity.setRequestBody(HarClient.toBizContent(req));
         entity.setRetryCount(0);
@@ -176,6 +283,26 @@ public class HarShopService {
         return HarShopVO.of(entity);
     }
 
+    /**
+     * 地区编码对表:省码要认识,市码还要**属于这个省**(市码本身全国唯一,但「这个码属于
+     * 这个省吗」必须连省一起看,否则省码写错也照样放行)。
+     *
+     * <p>省码与市码分开报错,是为了让运营照着字面就能改对 —— 「省市不匹配」这种笼统提示
+     * 会让人去翻数据字典。
+     *
+     * @return 目录里那条地级记录,调用方用它的 code 落库/发上游(即**规范化后的**编码)
+     */
+    private HarRegionCatalog.City requireKnownCity(String provinceCode, String cityCode) {
+        String pc = provinceCode == null ? "" : provinceCode.trim();
+        boolean provinceKnown = regionCatalog.provinces().stream().anyMatch(p -> pc.equals(p.code()));
+        if (!provinceKnown) {
+            throw new BusinessException("省份编码不存在: " + provinceCode);
+        }
+        return regionCatalog.cityByCode(pc, cityCode)
+                .orElseThrow(() -> new BusinessException(
+                        "城市编码不属于该省: " + cityCode + "(省 " + provinceCode + ")"));
+    }
+
     /**
      * 把响应还原成可读报文存库,供超时后人工核实远端状态。
      *

+ 267 - 0
java/src/main/java/com/payment/platform/module/payment/har/util/HarRegionCatalog.java

@@ -0,0 +1,267 @@
+package com.payment.platform.module.payment.har.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 省 / 地级目录(快照)+ 归一化 + 按名匹配。
+ *
+ * <h2>它为什么存在</h2>
+ * 支付宝对**异地收款**有风控(2026-10-08 用户口径),所以收款链接那条腿要在下单时
+ * 按「请求者 IP 的归属地」挑一家**同地区**的门店(门店新建了省份/市两个必填字段,
+ * 见规范 5.5)。挑之前先得把上游 IP 库给的中文地名对到我们的编码上 —— 这个类就是那座桥,
+ * 同时也是门店表单下拉的**唯一**数据来源(前端不另存一份省市表)。
+ *
+ * <h2>数据来源与快照</h2>
+ * 快照打包在 {@code /har-region-cn.json}(31 个省级 / 342 个地级),由
+ * {@code logs/_har-region-gen.py}(**不进版本库**)从下面三份外部数据派生成,资源里的
+ * {@code source} 块记着同样的出处与 sha256:
+ * <ul>
+ *   <li>省市:{@code modood/Administrative-divisions-of-China} 的 {@code dist/provinces.json}
+ *       + {@code dist/cities.json}(GB/T 2260 编码);</li>
+ *   <li>繁简:{@code BYVoid/OpenCC} 的 {@code data/dictionary/TSCharacters.txt}(Apache-2.0),
+ *       **只取其中指向本目录用字的繁体键**(166 条)。</li>
+ * </ul>
+ * <p>★ 快照是**活的**:换数据只需重跑生成脚本,但换完必须复看
+ * {@code HarRegionCatalogTest} 里那几条钉住条数的断言(它们红了就是提醒你这一点)。
+ *
+ * <h2>三条不看代码看不出来的规矩</h2>
+ * <ol>
+ *   <li><b>只含大陆 31 个省级</b>:港澳台不在内。ipwho.is 对它们的 {@code country}
+ *       不是「中国」,解析器那一步就先判掉了,这里不需要它们;</li>
+ *   <li><b>占位名按省名展示</b>:GB/T 2260 里直辖市的城市码是 1101/1201/3101/5001,
+ *       名字是占位的「市辖区」(5002 是「县」)。生成脚本把它们换成省名
+ *       ({@code 北京市} / {@code 重庆市县})—— 既让下拉能读,也让「北京市 + 北京」
+ *       这种回包能命中**市**一级而不必回退到全省;</li>
+ *   <li><b>繁简表的键不得是目录自己的字</b>:{@code 沈} 是 {@code 沉} 的繁体变体,
+ *       而「沈阳市」在目录里 —— 若把它当键,归一化会把沈阳市改成沉阳市,
+ *       匹配全线失守且**没有任何报错**。生成脚本按此剔键(实测剔掉了 {@code 沈} 一个),
+ *       守卫测试把它钉住。</li>
+ * </ol>
+ *
+ * <h2>为什么这里用 {@code Optional}</h2>
+ * 本仓其它地方「查不到」一律返回 {@code null}(如 {@code HarShopService#getByThirdShopId})。
+ * 这里有意偏离:下游是一条**每一步都要显式分叉到「拒收」**的阶梯(解析 → 省 → 市 → 门店),
+ * {@code null} 漏判会变成一次 NPE,而 NPE 在顾客页上是一句 500 —— 与「拒收」在
+ * 顾客那里长得一样、在日志里却完全不同。{@code Optional} 让每一步的空都藏不住。
+ *
+ * <p>★ 归一化是**对称**的(匹配双方过同一个 {@link #normalize}),所以规则本身不必完备,
+ * 只要不会让目录内两个不同的名字撞成同一个({@code HarRegionCatalogTest} 钉住)。
+ */
+@Slf4j
+@Component
+public class HarRegionCatalog {
+
+    private static final String RESOURCE = "/har-region-cn.json";
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    /**
+     * 归一化时剥掉的行政后缀,**长的在前**({@code 自治区} 必须先于 {@code 区} 被匹配到,
+     * 虽然本表里没有裸 {@code 区})。
+     */
+    private static final List<String> SUFFIXES = List.of(
+            "特别行政区", "自治区", "自治州", "自治县", "地区", "林区", "特区", "盟", "省", "市");
+
+    /** 族名:{@code 广西壮族自治区} / {@code 昌吉回族自治州} 去掉它才能与简称对上 */
+    private static final List<String> ETHNONYMS = List.of("壮族", "回族", "维吾尔");
+
+    /** 省级。{@code code} 是 GB/T 2260 两位省码。 */
+    public record Province(String code, String name) {
+    }
+
+    /** 地级。{@code code} 四位;{@code name} 已是**展示名**(见类 javadoc 第 2 条)。 */
+    public record City(String code, String name, String provinceCode) {
+    }
+
+    private final List<Province> provinces;
+    private final Map<String, List<City>> citiesByProvince;
+    private final Map<String, Province> provinceByNormalized;
+    private final Map<String, Map<String, City>> cityByNormalizedByProvince;
+    private final Map<Character, Character> tsTable;
+
+    /**
+     * 启动即装载:资源缺了/坏了 ⇒ 直接抛,宁可**起不来**也不要让顾客在收款页上
+     * 收到一句莫名其妙的拒收。{@code @Component} 保证这一跳发生在启动期。
+     */
+    public HarRegionCatalog() {
+        JsonNode root = readResource();
+
+        Map<Character, Character> ts = new HashMap<>();
+        var tsIt = root.path("t2s").fields();
+        while (tsIt.hasNext()) {
+            Map.Entry<String, JsonNode> e = tsIt.next();
+            String k = e.getKey();
+            String v = e.getValue().asText();
+            if (k.length() != 1 || v.length() != 1) {
+                throw new IllegalStateException("繁简表只收单字对: " + k + " -> " + v);
+            }
+            ts.put(k.charAt(0), v.charAt(0));
+        }
+
+        List<Province> ps = new ArrayList<>();
+        Map<String, Province> pByNorm = new HashMap<>();
+        for (JsonNode n : root.path("provinces")) {
+            Province p = new Province(n.path("code").asText(), n.path("name").asText());
+            String norm = normalize(ts, p.name());
+            Province dup = pByNorm.put(norm, p);
+            if (dup != null) {
+                throw new IllegalStateException("省级归一化后撞名: " + dup.name() + " / " + p.name());
+            }
+            ps.add(p);
+        }
+
+        Map<String, List<City>> cByProvince = new LinkedHashMap<>();
+        Map<String, Map<String, City>> cByNormByProvince = new HashMap<>();
+        for (JsonNode n : root.path("cities")) {
+            City c = new City(n.path("code").asText(), n.path("name").asText(),
+                    n.path("provinceCode").asText());
+            cByProvince.computeIfAbsent(c.provinceCode(), k -> new ArrayList<>()).add(c);
+            Map<String, City> byNorm = cByNormByProvince.computeIfAbsent(c.provinceCode(), k -> new HashMap<>());
+            City dup = byNorm.put(normalize(ts, c.name()), c);
+            if (dup != null) {
+                throw new IllegalStateException("同省地级归一化后撞名: " + dup.name() + " / " + c.name());
+            }
+        }
+
+        this.tsTable = Collections.unmodifiableMap(ts);
+        this.provinces = List.copyOf(ps);
+        this.citiesByProvince = unmodifiableCities(cByProvince);
+        this.provinceByNormalized = Collections.unmodifiableMap(pByNorm);
+        this.cityByNormalizedByProvince = Collections.unmodifiableMap(cByNormByProvince);
+        log.info("[汇安融地区目录] 装载完成: 省={}, 市={}, 繁简表={}",
+                provinces.size(), root.path("cities").size(), tsTable.size());
+    }
+
+    public List<Province> provinces() {
+        return provinces;
+    }
+
+    /** 某省的地级(展示名),按编码顺序。省码未知 ⇒ 空表。 */
+    public List<City> cities(String provinceCode) {
+        return citiesByProvince.getOrDefault(provinceCode, List.of());
+    }
+
+    /**
+     * 按名匹配省级。{@code raw} 可以带后缀、用繁体、带空格。
+     *
+     * @return 匹配不到(空白 / 非大陆省级 / 名字不认识)一律空
+     */
+    public Optional<Province> matchProvince(String raw) {
+        return Optional.ofNullable(provinceByNormalized.get(normalize(raw)));
+    }
+
+    /**
+     * 按名匹配**该省内**的地级。
+     *
+     * <p>★ 有意限定在给定省内:地级名跨省会重({@code 吉林市} 在吉林省、
+     * {@code 朝阳市} 在辽宁省),全局搜会挑到别的省。
+     */
+    public Optional<City> matchCity(String provinceCode, String rawCity) {
+        Map<String, City> byNorm = cityByNormalizedByProvince.get(provinceCode);
+        if (byNorm == null) {
+            return Optional.empty();
+        }
+        return Optional.ofNullable(byNorm.get(normalize(rawCity)));
+    }
+
+    /**
+     * 按**编码**查该省内的地级 —— 门店创建时用它校验提交上来的编码。
+     *
+     * <p>为什么需要这道校验:门店建出来就**补不上**地区(上游没有门店查询/更新接口,
+     * 见 {@code HarShopEntity} 的 javadoc),编码写错的那家店在收款链接里永远挑不中,
+     * 而且不会有任何报错 —— 只能在建的那一刻拦住。
+     *
+     * <p>与 {@link #matchCity} 同一条口径:**限定在给定省内**。市码自身是全国唯一的,
+     * 但「这个码属于这个省吗」这个判定必须连省一起看,否则省码写错也照样放行。
+     */
+    public Optional<City> cityByCode(String provinceCode, String cityCode) {
+        if (provinceCode == null || cityCode == null) {
+            return Optional.empty();
+        }
+        String pc = provinceCode.trim();
+        String cc = cityCode.trim();
+        if (pc.isEmpty() || cc.isEmpty()) {
+            return Optional.empty();
+        }
+        return cities(pc).stream().filter(c -> cc.equals(c.code())).findFirst();
+    }
+
+    /**
+     * 归一化:繁→简、去空格、去行政后缀与族名。
+     *
+     * <p>匹配的双方都过这一个函数(对称),所以规则不必完备 —— 完备性由
+     * 「目录内不撞名」的守卫保证。
+     */
+    public String normalize(String raw) {
+        return normalize(tsTable, raw);
+    }
+
+    private static String normalize(Map<Character, Character> ts, String raw) {
+        if (raw == null) {
+            return "";
+        }
+        String s = raw.trim();
+        if (s.isEmpty()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder(s.length());
+        for (int i = 0; i < s.length(); i++) {
+            char c = s.charAt(i);
+            sb.append(ts.getOrDefault(c, c));
+        }
+        String t = sb.toString();
+        for (String e : ETHNONYMS) {
+            t = t.replace(e, "");
+        }
+        for (String suffix : SUFFIXES) {
+            if (t.length() > suffix.length() && t.endsWith(suffix)) {
+                return t.substring(0, t.length() - suffix.length());
+            }
+        }
+        return t;
+    }
+
+    /**
+     * 繁简表本体。**包内可见只为完整性守卫测试**(它要断言键不落在目录用字上)——
+     * 业务代码不该直接用它,请走 {@link #normalize}。
+     */
+    Map<Character, Character> tsTable() {
+        return tsTable;
+    }
+
+    private static JsonNode readResource() {
+        try (InputStream in = HarRegionCatalog.class.getResourceAsStream(RESOURCE)) {
+            if (in == null) {
+                throw new IllegalStateException("缺资源 " + RESOURCE + ":打包漏了或路径写错");
+            }
+            // ★ 显式 UTF-8:不押在平台默认编码上(构建机与运行机的 file.encoding 可以不同)
+            try (Reader r = new InputStreamReader(in, StandardCharsets.UTF_8)) {
+                return MAPPER.readTree(r);
+            }
+        } catch (IOException e) {
+            throw new IllegalStateException("读不了 " + RESOURCE, e);
+        }
+    }
+
+    private static Map<String, List<City>> unmodifiableCities(Map<String, List<City>> src) {
+        Map<String, List<City>> copy = new LinkedHashMap<>();
+        src.forEach((k, v) -> copy.put(k, List.copyOf(v)));
+        return Collections.unmodifiableMap(copy);
+    }
+}

+ 2019 - 0
java/src/main/resources/har-region-cn.json

@@ -0,0 +1,2019 @@
+{
+ "source": {
+  "dataset": "modood/Administrative-divisions-of-China dist/provinces.json + dist/cities.json",
+  "dataset_sha256": {
+   "provinces": "a7e6a230102fee054daa7f63d91ca8da4320c11a2ddd4e5f093999b579fd7d4f",
+   "cities": "3f569aaa0bfbeba72f1597657511c64f54107baae71710fef7146f390a41af32"
+  },
+  "t2s": "BYVoid/OpenCC data/dictionary/TSCharacters.txt (Apache-2.0),取其中指向数据集用字的繁体键",
+  "t2s_sha256": "9ff46a7d30e5765375eb13d33f2b03a34d298913caf2b120380679f33ae1642d",
+  "generated_by": "logs/_har-region-gen.py(不进版本库)",
+  "labels": "GB/T 2260 占位名 市辖区→省名、县→省名+县"
+ },
+ "provinces": [
+  {
+   "code": "11",
+   "name": "北京市"
+  },
+  {
+   "code": "12",
+   "name": "天津市"
+  },
+  {
+   "code": "13",
+   "name": "河北省"
+  },
+  {
+   "code": "14",
+   "name": "山西省"
+  },
+  {
+   "code": "15",
+   "name": "内蒙古自治区"
+  },
+  {
+   "code": "21",
+   "name": "辽宁省"
+  },
+  {
+   "code": "22",
+   "name": "吉林省"
+  },
+  {
+   "code": "23",
+   "name": "黑龙江省"
+  },
+  {
+   "code": "31",
+   "name": "上海市"
+  },
+  {
+   "code": "32",
+   "name": "江苏省"
+  },
+  {
+   "code": "33",
+   "name": "浙江省"
+  },
+  {
+   "code": "34",
+   "name": "安徽省"
+  },
+  {
+   "code": "35",
+   "name": "福建省"
+  },
+  {
+   "code": "36",
+   "name": "江西省"
+  },
+  {
+   "code": "37",
+   "name": "山东省"
+  },
+  {
+   "code": "41",
+   "name": "河南省"
+  },
+  {
+   "code": "42",
+   "name": "湖北省"
+  },
+  {
+   "code": "43",
+   "name": "湖南省"
+  },
+  {
+   "code": "44",
+   "name": "广东省"
+  },
+  {
+   "code": "45",
+   "name": "广西壮族自治区"
+  },
+  {
+   "code": "46",
+   "name": "海南省"
+  },
+  {
+   "code": "50",
+   "name": "重庆市"
+  },
+  {
+   "code": "51",
+   "name": "四川省"
+  },
+  {
+   "code": "52",
+   "name": "贵州省"
+  },
+  {
+   "code": "53",
+   "name": "云南省"
+  },
+  {
+   "code": "54",
+   "name": "西藏自治区"
+  },
+  {
+   "code": "61",
+   "name": "陕西省"
+  },
+  {
+   "code": "62",
+   "name": "甘肃省"
+  },
+  {
+   "code": "63",
+   "name": "青海省"
+  },
+  {
+   "code": "64",
+   "name": "宁夏回族自治区"
+  },
+  {
+   "code": "65",
+   "name": "新疆维吾尔自治区"
+  }
+ ],
+ "cities": [
+  {
+   "code": "1101",
+   "name": "北京市",
+   "provinceCode": "11"
+  },
+  {
+   "code": "1201",
+   "name": "天津市",
+   "provinceCode": "12"
+  },
+  {
+   "code": "1301",
+   "name": "石家庄市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1302",
+   "name": "唐山市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1303",
+   "name": "秦皇岛市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1304",
+   "name": "邯郸市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1305",
+   "name": "邢台市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1306",
+   "name": "保定市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1307",
+   "name": "张家口市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1308",
+   "name": "承德市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1309",
+   "name": "沧州市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1310",
+   "name": "廊坊市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1311",
+   "name": "衡水市",
+   "provinceCode": "13"
+  },
+  {
+   "code": "1401",
+   "name": "太原市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1402",
+   "name": "大同市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1403",
+   "name": "阳泉市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1404",
+   "name": "长治市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1405",
+   "name": "晋城市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1406",
+   "name": "朔州市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1407",
+   "name": "晋中市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1408",
+   "name": "运城市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1409",
+   "name": "忻州市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1410",
+   "name": "临汾市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1411",
+   "name": "吕梁市",
+   "provinceCode": "14"
+  },
+  {
+   "code": "1501",
+   "name": "呼和浩特市",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1502",
+   "name": "包头市",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1503",
+   "name": "乌海市",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1504",
+   "name": "赤峰市",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1505",
+   "name": "通辽市",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1506",
+   "name": "鄂尔多斯市",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1507",
+   "name": "呼伦贝尔市",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1508",
+   "name": "巴彦淖尔市",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1509",
+   "name": "乌兰察布市",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1522",
+   "name": "兴安盟",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1525",
+   "name": "锡林郭勒盟",
+   "provinceCode": "15"
+  },
+  {
+   "code": "1529",
+   "name": "阿拉善盟",
+   "provinceCode": "15"
+  },
+  {
+   "code": "2101",
+   "name": "沈阳市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2102",
+   "name": "大连市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2103",
+   "name": "鞍山市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2104",
+   "name": "抚顺市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2105",
+   "name": "本溪市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2106",
+   "name": "丹东市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2107",
+   "name": "锦州市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2108",
+   "name": "营口市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2109",
+   "name": "阜新市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2110",
+   "name": "辽阳市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2111",
+   "name": "盘锦市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2112",
+   "name": "铁岭市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2113",
+   "name": "朝阳市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2114",
+   "name": "葫芦岛市",
+   "provinceCode": "21"
+  },
+  {
+   "code": "2201",
+   "name": "长春市",
+   "provinceCode": "22"
+  },
+  {
+   "code": "2202",
+   "name": "吉林市",
+   "provinceCode": "22"
+  },
+  {
+   "code": "2203",
+   "name": "四平市",
+   "provinceCode": "22"
+  },
+  {
+   "code": "2204",
+   "name": "辽源市",
+   "provinceCode": "22"
+  },
+  {
+   "code": "2205",
+   "name": "通化市",
+   "provinceCode": "22"
+  },
+  {
+   "code": "2206",
+   "name": "白山市",
+   "provinceCode": "22"
+  },
+  {
+   "code": "2207",
+   "name": "松原市",
+   "provinceCode": "22"
+  },
+  {
+   "code": "2208",
+   "name": "白城市",
+   "provinceCode": "22"
+  },
+  {
+   "code": "2224",
+   "name": "延边朝鲜族自治州",
+   "provinceCode": "22"
+  },
+  {
+   "code": "2301",
+   "name": "哈尔滨市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2302",
+   "name": "齐齐哈尔市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2303",
+   "name": "鸡西市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2304",
+   "name": "鹤岗市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2305",
+   "name": "双鸭山市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2306",
+   "name": "大庆市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2307",
+   "name": "伊春市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2308",
+   "name": "佳木斯市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2309",
+   "name": "七台河市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2310",
+   "name": "牡丹江市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2311",
+   "name": "黑河市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2312",
+   "name": "绥化市",
+   "provinceCode": "23"
+  },
+  {
+   "code": "2327",
+   "name": "大兴安岭地区",
+   "provinceCode": "23"
+  },
+  {
+   "code": "3101",
+   "name": "上海市",
+   "provinceCode": "31"
+  },
+  {
+   "code": "3201",
+   "name": "南京市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3202",
+   "name": "无锡市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3203",
+   "name": "徐州市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3204",
+   "name": "常州市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3205",
+   "name": "苏州市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3206",
+   "name": "南通市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3207",
+   "name": "连云港市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3208",
+   "name": "淮安市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3209",
+   "name": "盐城市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3210",
+   "name": "扬州市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3211",
+   "name": "镇江市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3212",
+   "name": "泰州市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3213",
+   "name": "宿迁市",
+   "provinceCode": "32"
+  },
+  {
+   "code": "3301",
+   "name": "杭州市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3302",
+   "name": "宁波市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3303",
+   "name": "温州市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3304",
+   "name": "嘉兴市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3305",
+   "name": "湖州市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3306",
+   "name": "绍兴市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3307",
+   "name": "金华市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3308",
+   "name": "衢州市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3309",
+   "name": "舟山市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3310",
+   "name": "台州市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3311",
+   "name": "丽水市",
+   "provinceCode": "33"
+  },
+  {
+   "code": "3401",
+   "name": "合肥市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3402",
+   "name": "芜湖市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3403",
+   "name": "蚌埠市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3404",
+   "name": "淮南市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3405",
+   "name": "马鞍山市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3406",
+   "name": "淮北市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3407",
+   "name": "铜陵市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3408",
+   "name": "安庆市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3410",
+   "name": "黄山市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3411",
+   "name": "滁州市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3412",
+   "name": "阜阳市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3413",
+   "name": "宿州市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3415",
+   "name": "六安市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3416",
+   "name": "亳州市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3417",
+   "name": "池州市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3418",
+   "name": "宣城市",
+   "provinceCode": "34"
+  },
+  {
+   "code": "3501",
+   "name": "福州市",
+   "provinceCode": "35"
+  },
+  {
+   "code": "3502",
+   "name": "厦门市",
+   "provinceCode": "35"
+  },
+  {
+   "code": "3503",
+   "name": "莆田市",
+   "provinceCode": "35"
+  },
+  {
+   "code": "3504",
+   "name": "三明市",
+   "provinceCode": "35"
+  },
+  {
+   "code": "3505",
+   "name": "泉州市",
+   "provinceCode": "35"
+  },
+  {
+   "code": "3506",
+   "name": "漳州市",
+   "provinceCode": "35"
+  },
+  {
+   "code": "3507",
+   "name": "南平市",
+   "provinceCode": "35"
+  },
+  {
+   "code": "3508",
+   "name": "龙岩市",
+   "provinceCode": "35"
+  },
+  {
+   "code": "3509",
+   "name": "宁德市",
+   "provinceCode": "35"
+  },
+  {
+   "code": "3601",
+   "name": "南昌市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3602",
+   "name": "景德镇市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3603",
+   "name": "萍乡市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3604",
+   "name": "九江市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3605",
+   "name": "新余市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3606",
+   "name": "鹰潭市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3607",
+   "name": "赣州市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3608",
+   "name": "吉安市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3609",
+   "name": "宜春市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3610",
+   "name": "抚州市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3611",
+   "name": "上饶市",
+   "provinceCode": "36"
+  },
+  {
+   "code": "3701",
+   "name": "济南市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3702",
+   "name": "青岛市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3703",
+   "name": "淄博市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3704",
+   "name": "枣庄市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3705",
+   "name": "东营市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3706",
+   "name": "烟台市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3707",
+   "name": "潍坊市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3708",
+   "name": "济宁市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3709",
+   "name": "泰安市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3710",
+   "name": "威海市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3711",
+   "name": "日照市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3713",
+   "name": "临沂市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3714",
+   "name": "德州市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3715",
+   "name": "聊城市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3716",
+   "name": "滨州市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "3717",
+   "name": "菏泽市",
+   "provinceCode": "37"
+  },
+  {
+   "code": "4101",
+   "name": "郑州市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4102",
+   "name": "开封市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4103",
+   "name": "洛阳市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4104",
+   "name": "平顶山市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4105",
+   "name": "安阳市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4106",
+   "name": "鹤壁市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4107",
+   "name": "新乡市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4108",
+   "name": "焦作市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4109",
+   "name": "濮阳市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4110",
+   "name": "许昌市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4111",
+   "name": "漯河市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4112",
+   "name": "三门峡市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4113",
+   "name": "南阳市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4114",
+   "name": "商丘市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4115",
+   "name": "信阳市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4116",
+   "name": "周口市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4117",
+   "name": "驻马店市",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4190",
+   "name": "省直辖县级行政区划",
+   "provinceCode": "41"
+  },
+  {
+   "code": "4201",
+   "name": "武汉市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4202",
+   "name": "黄石市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4203",
+   "name": "十堰市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4205",
+   "name": "宜昌市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4206",
+   "name": "襄阳市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4207",
+   "name": "鄂州市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4208",
+   "name": "荆门市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4209",
+   "name": "孝感市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4210",
+   "name": "荆州市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4211",
+   "name": "黄冈市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4212",
+   "name": "咸宁市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4213",
+   "name": "随州市",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4228",
+   "name": "恩施土家族苗族自治州",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4290",
+   "name": "省直辖县级行政区划",
+   "provinceCode": "42"
+  },
+  {
+   "code": "4301",
+   "name": "长沙市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4302",
+   "name": "株洲市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4303",
+   "name": "湘潭市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4304",
+   "name": "衡阳市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4305",
+   "name": "邵阳市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4306",
+   "name": "岳阳市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4307",
+   "name": "常德市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4308",
+   "name": "张家界市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4309",
+   "name": "益阳市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4310",
+   "name": "郴州市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4311",
+   "name": "永州市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4312",
+   "name": "怀化市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4313",
+   "name": "娄底市",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4331",
+   "name": "湘西土家族苗族自治州",
+   "provinceCode": "43"
+  },
+  {
+   "code": "4401",
+   "name": "广州市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4402",
+   "name": "韶关市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4403",
+   "name": "深圳市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4404",
+   "name": "珠海市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4405",
+   "name": "汕头市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4406",
+   "name": "佛山市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4407",
+   "name": "江门市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4408",
+   "name": "湛江市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4409",
+   "name": "茂名市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4412",
+   "name": "肇庆市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4413",
+   "name": "惠州市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4414",
+   "name": "梅州市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4415",
+   "name": "汕尾市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4416",
+   "name": "河源市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4417",
+   "name": "阳江市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4418",
+   "name": "清远市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4419",
+   "name": "东莞市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4420",
+   "name": "中山市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4451",
+   "name": "潮州市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4452",
+   "name": "揭阳市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4453",
+   "name": "云浮市",
+   "provinceCode": "44"
+  },
+  {
+   "code": "4501",
+   "name": "南宁市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4502",
+   "name": "柳州市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4503",
+   "name": "桂林市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4504",
+   "name": "梧州市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4505",
+   "name": "北海市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4506",
+   "name": "防城港市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4507",
+   "name": "钦州市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4508",
+   "name": "贵港市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4509",
+   "name": "玉林市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4510",
+   "name": "百色市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4511",
+   "name": "贺州市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4512",
+   "name": "河池市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4513",
+   "name": "来宾市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4514",
+   "name": "崇左市",
+   "provinceCode": "45"
+  },
+  {
+   "code": "4601",
+   "name": "海口市",
+   "provinceCode": "46"
+  },
+  {
+   "code": "4602",
+   "name": "三亚市",
+   "provinceCode": "46"
+  },
+  {
+   "code": "4603",
+   "name": "三沙市",
+   "provinceCode": "46"
+  },
+  {
+   "code": "4604",
+   "name": "儋州市",
+   "provinceCode": "46"
+  },
+  {
+   "code": "4690",
+   "name": "省直辖县级行政区划",
+   "provinceCode": "46"
+  },
+  {
+   "code": "5001",
+   "name": "重庆市",
+   "provinceCode": "50"
+  },
+  {
+   "code": "5002",
+   "name": "重庆市县",
+   "provinceCode": "50"
+  },
+  {
+   "code": "5101",
+   "name": "成都市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5103",
+   "name": "自贡市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5104",
+   "name": "攀枝花市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5105",
+   "name": "泸州市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5106",
+   "name": "德阳市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5107",
+   "name": "绵阳市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5108",
+   "name": "广元市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5109",
+   "name": "遂宁市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5110",
+   "name": "内江市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5111",
+   "name": "乐山市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5113",
+   "name": "南充市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5114",
+   "name": "眉山市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5115",
+   "name": "宜宾市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5116",
+   "name": "广安市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5117",
+   "name": "达州市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5118",
+   "name": "雅安市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5119",
+   "name": "巴中市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5120",
+   "name": "资阳市",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5132",
+   "name": "阿坝藏族羌族自治州",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5133",
+   "name": "甘孜藏族自治州",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5134",
+   "name": "凉山彝族自治州",
+   "provinceCode": "51"
+  },
+  {
+   "code": "5201",
+   "name": "贵阳市",
+   "provinceCode": "52"
+  },
+  {
+   "code": "5202",
+   "name": "六盘水市",
+   "provinceCode": "52"
+  },
+  {
+   "code": "5203",
+   "name": "遵义市",
+   "provinceCode": "52"
+  },
+  {
+   "code": "5204",
+   "name": "安顺市",
+   "provinceCode": "52"
+  },
+  {
+   "code": "5205",
+   "name": "毕节市",
+   "provinceCode": "52"
+  },
+  {
+   "code": "5206",
+   "name": "铜仁市",
+   "provinceCode": "52"
+  },
+  {
+   "code": "5223",
+   "name": "黔西南布依族苗族自治州",
+   "provinceCode": "52"
+  },
+  {
+   "code": "5226",
+   "name": "黔东南苗族侗族自治州",
+   "provinceCode": "52"
+  },
+  {
+   "code": "5227",
+   "name": "黔南布依族苗族自治州",
+   "provinceCode": "52"
+  },
+  {
+   "code": "5301",
+   "name": "昆明市",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5303",
+   "name": "曲靖市",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5304",
+   "name": "玉溪市",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5305",
+   "name": "保山市",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5306",
+   "name": "昭通市",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5307",
+   "name": "丽江市",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5308",
+   "name": "普洱市",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5309",
+   "name": "临沧市",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5323",
+   "name": "楚雄彝族自治州",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5325",
+   "name": "红河哈尼族彝族自治州",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5326",
+   "name": "文山壮族苗族自治州",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5328",
+   "name": "西双版纳傣族自治州",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5329",
+   "name": "大理白族自治州",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5331",
+   "name": "德宏傣族景颇族自治州",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5333",
+   "name": "怒江傈僳族自治州",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5334",
+   "name": "迪庆藏族自治州",
+   "provinceCode": "53"
+  },
+  {
+   "code": "5401",
+   "name": "拉萨市",
+   "provinceCode": "54"
+  },
+  {
+   "code": "5402",
+   "name": "日喀则市",
+   "provinceCode": "54"
+  },
+  {
+   "code": "5403",
+   "name": "昌都市",
+   "provinceCode": "54"
+  },
+  {
+   "code": "5404",
+   "name": "林芝市",
+   "provinceCode": "54"
+  },
+  {
+   "code": "5405",
+   "name": "山南市",
+   "provinceCode": "54"
+  },
+  {
+   "code": "5406",
+   "name": "那曲市",
+   "provinceCode": "54"
+  },
+  {
+   "code": "5425",
+   "name": "阿里地区",
+   "provinceCode": "54"
+  },
+  {
+   "code": "6101",
+   "name": "西安市",
+   "provinceCode": "61"
+  },
+  {
+   "code": "6102",
+   "name": "铜川市",
+   "provinceCode": "61"
+  },
+  {
+   "code": "6103",
+   "name": "宝鸡市",
+   "provinceCode": "61"
+  },
+  {
+   "code": "6104",
+   "name": "咸阳市",
+   "provinceCode": "61"
+  },
+  {
+   "code": "6105",
+   "name": "渭南市",
+   "provinceCode": "61"
+  },
+  {
+   "code": "6106",
+   "name": "延安市",
+   "provinceCode": "61"
+  },
+  {
+   "code": "6107",
+   "name": "汉中市",
+   "provinceCode": "61"
+  },
+  {
+   "code": "6108",
+   "name": "榆林市",
+   "provinceCode": "61"
+  },
+  {
+   "code": "6109",
+   "name": "安康市",
+   "provinceCode": "61"
+  },
+  {
+   "code": "6110",
+   "name": "商洛市",
+   "provinceCode": "61"
+  },
+  {
+   "code": "6201",
+   "name": "兰州市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6202",
+   "name": "嘉峪关市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6203",
+   "name": "金昌市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6204",
+   "name": "白银市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6205",
+   "name": "天水市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6206",
+   "name": "武威市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6207",
+   "name": "张掖市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6208",
+   "name": "平凉市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6209",
+   "name": "酒泉市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6210",
+   "name": "庆阳市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6211",
+   "name": "定西市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6212",
+   "name": "陇南市",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6229",
+   "name": "临夏回族自治州",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6230",
+   "name": "甘南藏族自治州",
+   "provinceCode": "62"
+  },
+  {
+   "code": "6301",
+   "name": "西宁市",
+   "provinceCode": "63"
+  },
+  {
+   "code": "6302",
+   "name": "海东市",
+   "provinceCode": "63"
+  },
+  {
+   "code": "6322",
+   "name": "海北藏族自治州",
+   "provinceCode": "63"
+  },
+  {
+   "code": "6323",
+   "name": "黄南藏族自治州",
+   "provinceCode": "63"
+  },
+  {
+   "code": "6325",
+   "name": "海南藏族自治州",
+   "provinceCode": "63"
+  },
+  {
+   "code": "6326",
+   "name": "果洛藏族自治州",
+   "provinceCode": "63"
+  },
+  {
+   "code": "6327",
+   "name": "玉树藏族自治州",
+   "provinceCode": "63"
+  },
+  {
+   "code": "6328",
+   "name": "海西蒙古族藏族自治州",
+   "provinceCode": "63"
+  },
+  {
+   "code": "6401",
+   "name": "银川市",
+   "provinceCode": "64"
+  },
+  {
+   "code": "6402",
+   "name": "石嘴山市",
+   "provinceCode": "64"
+  },
+  {
+   "code": "6403",
+   "name": "吴忠市",
+   "provinceCode": "64"
+  },
+  {
+   "code": "6404",
+   "name": "固原市",
+   "provinceCode": "64"
+  },
+  {
+   "code": "6405",
+   "name": "中卫市",
+   "provinceCode": "64"
+  },
+  {
+   "code": "6501",
+   "name": "乌鲁木齐市",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6502",
+   "name": "克拉玛依市",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6504",
+   "name": "吐鲁番市",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6505",
+   "name": "哈密市",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6523",
+   "name": "昌吉回族自治州",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6527",
+   "name": "博尔塔拉蒙古自治州",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6528",
+   "name": "巴音郭楞蒙古自治州",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6529",
+   "name": "阿克苏地区",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6530",
+   "name": "克孜勒苏柯尔克孜自治州",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6531",
+   "name": "喀什地区",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6532",
+   "name": "和田地区",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6540",
+   "name": "伊犁哈萨克自治州",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6542",
+   "name": "塔城地区",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6543",
+   "name": "阿勒泰地区",
+   "provinceCode": "65"
+  },
+  {
+   "code": "6590",
+   "name": "自治区直辖县级行政区划",
+   "provinceCode": "65"
+  }
+ ],
+ "t2s": {
+  "亞": "亚",
+  "仝": "同",
+  "佈": "布",
+  "來": "来",
+  "倫": "伦",
+  "傢": "家",
+  "內": "内",
+  "則": "则",
+  "剋": "克",
+  "劃": "划",
+  "區": "区",
+  "吳": "吴",
+  "呂": "吕",
+  "哩": "里",
+  "囌": "苏",
+  "垻": "坝",
+  "壩": "坝",
+  "壯": "壮",
+  "婁": "娄",
+  "寧": "宁",
+  "寶": "宝",
+  "岡": "冈",
+  "峯": "峰",
+  "島": "岛",
+  "峽": "峡",
+  "崑": "昆",
+  "崗": "岗",
+  "嶺": "岭",
+  "嶽": "岳",
+  "巖": "岩",
+  "廈": "厦",
+  "廣": "广",
+  "張": "张",
+  "彥": "彦",
+  "彿": "佛",
+  "慶": "庆",
+  "懞": "蒙",
+  "懷": "怀",
+  "揚": "扬",
+  "撫": "抚",
+  "晉": "晋",
+  "東": "东",
+  "棗": "枣",
+  "樂": "乐",
+  "樑": "梁",
+  "樹": "树",
+  "檯": "台",
+  "欽": "钦",
+  "涼": "凉",
+  "溫": "温",
+  "滄": "沧",
+  "漢": "汉",
+  "澤": "泽",
+  "濛": "蒙",
+  "濟": "济",
+  "濰": "潍",
+  "濱": "滨",
+  "瀋": "沈",
+  "瀘": "泸",
+  "烏": "乌",
+  "無": "无",
+  "煙": "烟",
+  "營": "营",
+  "爾": "尔",
+  "瑪": "玛",
+  "甦": "苏",
+  "甯": "宁",
+  "畢": "毕",
+  "畫": "划",
+  "盤": "盘",
+  "矇": "蒙",
+  "節": "节",
+  "紅": "红",
+  "納": "纳",
+  "級": "级",
+  "紹": "绍",
+  "綏": "绥",
+  "維": "维",
+  "綿": "绵",
+  "縣": "县",
+  "義": "义",
+  "肅": "肃",
+  "臨": "临",
+  "臺": "台",
+  "興": "兴",
+  "荊": "荆",
+  "莊": "庄",
+  "華": "华",
+  "菸": "烟",
+  "蕪": "芜",
+  "薩": "萨",
+  "蘆": "芦",
+  "蘇": "苏",
+  "蘭": "兰",
+  "衕": "同",
+  "衛": "卫",
+  "裏": "里",
+  "裡": "里",
+  "許": "许",
+  "谿": "溪",
+  "貝": "贝",
+  "貢": "贡",
+  "貲": "资",
+  "貴": "贵",
+  "賀": "贺",
+  "資": "资",
+  "賓": "宾",
+  "贛": "赣",
+  "轄": "辖",
+  "迴": "回",
+  "連": "连",
+  "週": "周",
+  "運": "运",
+  "達": "达",
+  "遠": "远",
+  "遷": "迁",
+  "遼": "辽",
+  "邊": "边",
+  "鄉": "乡",
+  "鄭": "郑",
+  "鄲": "郸",
+  "銀": "银",
+  "銅": "铜",
+  "錦": "锦",
+  "錫": "锡",
+  "鎭": "镇",
+  "鎮": "镇",
+  "鐵": "铁",
+  "長": "长",
+  "門": "门",
+  "開": "开",
+  "閤": "合",
+  "關": "关",
+  "陝": "陕",
+  "陽": "阳",
+  "隨": "随",
+  "隴": "陇",
+  "雙": "双",
+  "雞": "鸡",
+  "雲": "云",
+  "頂": "顶",
+  "順": "顺",
+  "頗": "颇",
+  "頭": "头",
+  "颱": "台",
+  "颺": "扬",
+  "餘": "余",
+  "饒": "饶",
+  "馬": "马",
+  "駐": "驻",
+  "鬆": "松",
+  "魯": "鲁",
+  "鮮": "鲜",
+  "鴨": "鸭",
+  "鶴": "鹤",
+  "鷄": "鸡",
+  "鷹": "鹰",
+  "鹹": "咸",
+  "鹽": "盐",
+  "麗": "丽",
+  "麯": "曲",
+  "麴": "曲",
+  "黃": "黄",
+  "齊": "齐",
+  "龍": "龙",
+  "龢": "和"
+ }
+}

+ 262 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarIpRegionResolverTest.java

@@ -0,0 +1,262 @@
+package com.payment.platform.module.payment.har.service;
+
+import com.payment.platform.module.payment.har.config.HarProperties;
+import com.payment.platform.module.payment.har.service.HarIpRegionResolver.IpGeo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * IP 归属地解析器。
+ *
+ * <p>下面 5 段 JSON 是 2026-10-08 用 curl 从 {@code https://ipwho.is/{ip}?lang=zh-CN}
+ * 抄回来的**真实回包**(末尾的 flag/connection/timezone/readme 四个嵌套对象原样略去,
+ * 其余逐字未改);末一条是构造输入,已标注。用真实回包是因为这个解析器的全部工作
+ * 就是「上游到底怎么回」,自造的理想 JSON 会把真实坑(繁体「區」、区级 city 名)滤掉。
+ */
+class HarIpRegionResolverTest {
+
+    /** 实测:114.114.114.114 → 江苏省 / 南京 */
+    private static final String P_JS_NANJING = """
+            {"ip":"114.114.114.114","success":true,"type":"IPv4","continent":"亚洲","continent_code":"AS",
+             "country":"中国","country_code":"CN","region":"江苏省","region_code":"JS","city":"南京",
+             "latitude":32.0616657,"longitude":118.7777759,"is_eu":false,"postal":"210000","calling_code":"86",
+             "capital":"北京"}""";
+
+    /** 实测:218.201.4.2 → 重庆市 / **渝中區**(区级名 + 繁体「區」) */
+    private static final String P_CQ_YUZHONG = """
+            {"ip":"218.201.4.2","success":true,"type":"IPv4","continent":"亚洲","continent_code":"AS",
+             "country":"中国","country_code":"CN","region":"重庆市","region_code":"CQ","city":"渝中區",
+             "latitude":29.5601743,"longitude":106.5576688,"is_eu":false,"postal":"400014","calling_code":"86",
+             "capital":"北京"}""";
+
+    /** 实测:2400:3200::1(IPv6)→ 浙江省 / 杭州 */
+    private static final String P_V6_ZJ_HANGZHOU = """
+            {"ip":"2400:3200::1","success":true,"type":"IPv6","continent":"亚洲","continent_code":"AS",
+             "country":"中国","country_code":"CN","region":"浙江省","region_code":"ZJ","city":"杭州",
+             "latitude":30.2936456,"longitude":120.161422,"is_eu":false,"postal":"310000","calling_code":"86",
+             "capital":"北京"}""";
+
+    /** 实测:8.8.8.8 → 美国(非中国大陆) */
+    private static final String P_US = """
+            {"ip":"8.8.8.8","success":true,"type":"IPv4","continent":"北美","continent_code":"NA",
+             "country":"美国","country_code":"US","region":"加利福尼亚州","region_code":"CA","city":"聖荷西",
+             "latitude":37.3393939,"longitude":-121.8949553,"is_eu":false,"postal":"95025","calling_code":"1",
+             "capital":"华盛顿特区"}""";
+
+    /** 实测:not-an-ip → ipwho.is 直接回 404 体(也是 success:false 的真实形态) */
+    private static final String P_NOT_FOUND = """
+            {"success":false,"message":"404 not found"}""";
+
+    /** 构造输入(非实测):ipwho.is 前面挂了网关、网关回了一页 HTML —— 解析器不能因此抛出去 */
+    private static final String P_HTML_GATEWAY = "<html><head><title>502 Bad Gateway</title></head></html>";
+
+    private HarProperties properties;
+    private List<String> fetchedUrls;
+    private AtomicInteger calls;
+
+    @BeforeEach
+    void setUp() {
+        properties = new HarProperties();
+        fetchedUrls = new ArrayList<>();
+        calls = new AtomicInteger();
+    }
+
+    /** 一个把回包按 IP 写死的取数器,同时记账:调了几次、URL 长什么样。 */
+    private HarIpRegionResolver resolverReturning(Function<String, String> byUrl) {
+        return new HarIpRegionResolver(properties, url -> {
+            calls.incrementAndGet();
+            fetchedUrls.add(url);
+            return byUrl.apply(url);
+        });
+    }
+
+    private HarIpRegionResolver resolverReturning(String body) {
+        return resolverReturning(url -> body);
+    }
+
+    // ──────────────────────────── 正常解析 ────────────────────────────
+
+    @Test
+    void lookup_parsesRealNanjingPayload() {
+        IpGeo geo = resolverReturning(P_JS_NANJING).lookup("114.114.114.114").orElse(null);
+
+        assertEquals("CN", geo.countryCode());
+        assertEquals("江苏省", geo.regionName());
+        assertEquals("JS", geo.regionCode());
+        assertEquals("南京", geo.cityName());
+    }
+
+    /** ★ {@code ?lang=zh-CN} 是这套设计的前提:不带它上游回拼音/英文,按中文名匹配全线失效。 */
+    @Test
+    void lookup_asksForChineseNamesExplicitly() {
+        resolverReturning(P_JS_NANJING).lookup("114.114.114.114");
+
+        assertEquals("https://ipwho.is/114.114.114.114?lang=zh-CN", fetchedUrls.get(0));
+    }
+
+    @Test
+    void lookup_parsesIpv6() {
+        IpGeo geo = resolverReturning(P_V6_ZJ_HANGZHOU).lookup("2400:3200::1").orElse(null);
+
+        assertEquals("浙江省", geo.regionName());
+        assertEquals("杭州", geo.cityName());
+    }
+
+    /**
+     * 解析器**原样**交出上游给的地名,不在这里做归一化 —— 繁体/后缀/区级名都归
+     * {@code HarRegionCatalog} 管(它手里才有那份数据集)。这条边界钉住:
+     * 谁要是顺手在这儿 `trim` 掉「區」,匹配测试那边就会红得莫名其妙。
+     */
+    @Test
+    void lookup_handsOverTheRawNames() {
+        IpGeo geo = resolverReturning(P_CQ_YUZHONG).lookup("218.201.4.2").orElse(null);
+
+        assertEquals("重庆市", geo.regionName());
+        assertEquals("渝中區", geo.cityName());
+    }
+
+    // ──────────────────────────── 一律判空(下游据此拒收) ────────────────────────────
+
+    @Test
+    void lookup_nonMainlandChina_isEmpty() {
+        assertTrue(resolverReturning(P_US).lookup("8.8.8.8").isEmpty());
+    }
+
+    @Test
+    void lookup_successFalse_isEmpty() {
+        assertTrue(resolverReturning(P_NOT_FOUND).lookup("not-an-ip").isEmpty());
+    }
+
+    @Test
+    void lookup_nonJsonBody_isEmpty() {
+        assertTrue(resolverReturning(P_HTML_GATEWAY).lookup("114.114.114.114").isEmpty());
+    }
+
+    @Test
+    void lookup_fetcherThrows_isEmptyAndDoesNotEscape() {
+        HarIpRegionResolver resolver = new HarIpRegionResolver(properties, url -> {
+            throw new IllegalStateException("connection reset");
+        });
+
+        assertTrue(resolver.lookup("114.114.114.114").isEmpty(), "网络异常不能把异常抛到顾客页那条腿上");
+    }
+
+    /**
+     * 内网/回环地址不去问上游:问了也只会得到 "Reserved range",白等一个 RTT
+     * (还有把内网地址发给第三方的问题)。
+     */
+    @SuppressWarnings("unchecked")
+    @Test
+    void lookup_privateOrBlankIp_isEmptyWithoutCallingUpstream() {
+        Function<String, String> fetcher = mock(Function.class);
+        HarIpRegionResolver resolver = new HarIpRegionResolver(properties, fetcher);
+
+        for (String ip : new String[]{null, "", "   ", "127.0.0.1", "192.168.1.10", "10.0.0.1",
+                "172.20.3.4", "::1", "0:0:0:0:0:0:0:1", "unknown"}) {
+            assertTrue(resolver.lookup(ip).isEmpty(), "应判空: " + ip);
+        }
+        verifyNoInteractions(fetcher);
+    }
+
+    // ──────────────────────────── 缓存 ────────────────────────────
+
+    @Test
+    void lookup_sameIpIsCached() {
+        HarIpRegionResolver resolver = resolverReturning(P_JS_NANJING);
+
+        Optional<IpGeo> first = resolver.lookup("114.114.114.114");
+        Optional<IpGeo> second = resolver.lookup("114.114.114.114");
+
+        assertEquals(first.orElse(null), second.orElse(null));
+        assertEquals(1, calls.get(), "同一个 IP 在 TTL 内只该问一次上游");
+    }
+
+    /**
+     * 失败也要进缓存(短 TTL,见类 javadoc):否则上游一抖,每个顾客都要等满一个超时。
+     * ★ 这条与上一条一起定义了「缓存必须按 IP 分键」——没分键的话这条会假绿。
+     */
+    @Test
+    void lookup_negativeResultIsCachedToo() {
+        HarIpRegionResolver resolver = resolverReturning(P_US);
+
+        assertTrue(resolver.lookup("8.8.8.8").isEmpty());
+        assertTrue(resolver.lookup("8.8.8.8").isEmpty());
+        assertEquals(1, calls.get());
+    }
+
+    /**
+     * ★ 两档 TTL 是**分开的**,这条钉住「失败那档短」:同一次调用里 P_US 是**非 CN**
+     * (走失败档),短 TTL 一过必须重问上游 —— 否则上游抖一下就会把「拒收」按小时钉死一批顾客。
+     *
+     * <p>TTL 走可注入的那个构造器;生产路径永远用类里那两个常量(见类 javadoc)。
+     */
+    @Test
+    void lookup_negativeCacheExpiresQuicklySoItSelfHeals() throws InterruptedException {
+        AtomicInteger n = new AtomicInteger();
+        HarIpRegionResolver resolver = new HarIpRegionResolver(properties, url -> {
+            n.incrementAndGet();
+            return P_US;
+        }, 60_000L, 30L);
+
+        assertTrue(resolver.lookup("8.8.8.8").isEmpty());
+        Thread.sleep(200);
+        assertTrue(resolver.lookup("8.8.8.8").isEmpty());
+
+        assertEquals(2, n.get(), "失败档过期后必须重问上游");
+    }
+
+    /**
+     * 上一条的**正控** —— 没有它,上一条在「sleep 根本没生效」或「两档其实是同一个缓存」
+     * 这两种坏法下**也会绿**。同一组 TTL 下,成功结果必须熬过那段 sleep。
+     */
+    @Test
+    void lookup_successCacheIsNotGovernedByTheShortMissTtl() throws InterruptedException {
+        AtomicInteger n = new AtomicInteger();
+        HarIpRegionResolver resolver = new HarIpRegionResolver(properties, url -> {
+            n.incrementAndGet();
+            return P_JS_NANJING;
+        }, 60_000L, 30L);
+
+        resolver.lookup("114.114.114.114");
+        Thread.sleep(200);
+        resolver.lookup("114.114.114.114");
+
+        assertEquals(1, n.get(), "成功档有自己的长 TTL,不该被失败档的 30ms 带走");
+    }
+
+    @Test
+    void lookup_differentIpsHitUpstreamSeparately() {
+        HarIpRegionResolver resolver = resolverReturning(P_JS_NANJING);
+
+        resolver.lookup("114.114.114.114");
+        resolver.lookup("223.5.5.5");
+
+        assertEquals(2, calls.get());
+    }
+
+    /** URL 里带着原始 IP:缓存分键若被写错,取数器看到的 URL 会先露馅 */
+    @Test
+    void lookup_urlCarriesTheAskedIp() {
+        AtomicReference<String> seen = new AtomicReference<>();
+        HarIpRegionResolver resolver = new HarIpRegionResolver(properties, url -> {
+            seen.set(url);
+            return P_JS_NANJING;
+        });
+
+        resolver.lookup("223.5.5.5");
+
+        assertTrue(seen.get().startsWith("https://ipwho.is/223.5.5.5?"), "实际: " + seen.get());
+    }
+}

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

@@ -0,0 +1,242 @@
+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.module.payment.har.client.HarClient;
+import com.payment.platform.module.payment.har.entity.HarShopEntity;
+import com.payment.platform.module.payment.har.enums.HarShopCreateStatus;
+import com.payment.platform.module.payment.har.mapper.HarShopMapper;
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 按地区挑门店(收款链接下单时用;支付宝对**异地收款**有风控)。
+ *
+ * <p>阶梯只有两级,但每一级的边界都要钉住:同城多店怎么选、同城没店怎么办、
+ * 已失败/未成功建出来的店算不算、以及「挑不出来」必须是**空**而不是随便给一家 ——
+ * 给错地区比拒收更糟(那正是这套东西要规避的风控)。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarShopRegionPickTest {
+
+    @Mock private HarShopMapper shopMapper;
+    @Mock private HarMerchantService merchantService;
+    @Mock private HarClient harClient;
+
+    private HarShopService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog());
+        // 同 HarShopServiceTest:让 LambdaQueryWrapper.getSqlSegment() 能在无 Spring 上下文时工作
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarShopEntity.class);
+    }
+
+    private static HarShopEntity shop(String thirdShopId, String province, String city, String status, String shopId) {
+        HarShopEntity e = new HarShopEntity();
+        e.setThirdShopId(thirdShopId);
+        e.setMerchantCode("M001");
+        e.setProvinceCode(province);
+        e.setCityCode(city);
+        e.setCreateStatus(status);
+        e.setShopId(shopId);
+        return e;
+    }
+
+    private static HarShopEntity ok(String thirdShopId, String province, String city) {
+        return shop(thirdShopId, province, city, HarShopCreateStatus.SUCCESS, "S-" + thirdShopId);
+    }
+
+    private static HarShopEntity pick(List<HarShopEntity> candidates, String province, String city, String seed) {
+        return HarShopService.pickFromCandidates(candidates, province, city, seed);
+    }
+
+    // ──────────────────────────── 两级阶梯 ────────────────────────────
+
+    /** 同城有店 ⇒ 就挑同城的,哪怕省内别处还有别的店 */
+    @Test
+    void pick_cityTierWins() {
+        List<HarShopEntity> candidates = List.of(
+                ok("NJ", "32", "3201"),
+                ok("SZ", "32", "3205"));
+
+        assertEquals("NJ", pick(candidates, "32", "3201", "114.114.114.114").getThirdShopId());
+    }
+
+    /** 同城没店 ⇒ 退到同省,且**只**在省内挑(异省的店绝不越界) */
+    @Test
+    void pick_cityMissing_fallsBackToTheSameProvinceOnly() {
+        List<HarShopEntity> candidates = List.of(
+                ok("SZ", "32", "3205"),
+                ok("WX", "32", "3202"),
+                ok("HZ", "33", "3301"));
+
+        HarShopEntity picked = pick(candidates, "32", "3201", "114.114.114.114");
+
+        assertNotNull(picked);
+        assertTrue(Set.of("SZ", "WX").contains(picked.getThirdShopId()),
+                "越省挑店正是要规避的风控,实际: " + picked.getThirdShopId());
+    }
+
+    /**
+     * 省市编码是**一对**:城市码虽是全国唯一,但一行数据若省码对不上(人工改错、跨库搬数据),
+     * 就不该被信任 —— 宁可不挑(下游拒收),也不要按一个自相矛盾的地址把顾客导去别的省。
+     */
+    @Test
+    void pick_cityCodeUnderAnotherProvinceIsNotTrusted() {
+        List<HarShopEntity> candidates = List.of(ok("X", "99", "3201"));
+
+        assertNull(pick(candidates, "32", "3201", "114.114.114.114"));
+    }
+
+    @Test
+    void pick_blankProvinceCode_picksNothing() {
+        List<HarShopEntity> candidates = List.of(ok("NJ", "32", "3201"));
+
+        assertNull(pick(candidates, null, "3201", "114.114.114.114"));
+        assertNull(pick(candidates, "  ", "3201", "114.114.114.114"));
+        // 正控:同一个候选集,省码给对就必须挑得出来(否则上两条是量具坏了在假绿)
+        assertNotNull(pick(candidates, "32", "3201", "114.114.114.114"));
+    }
+
+    /** 空 city 只走省级(ipwho.is 偶尔只给到省) */
+    @Test
+    void pick_blankCityCode_stillUsesTheProvinceTier() {
+        List<HarShopEntity> candidates = List.of(ok("NJ", "32", "3201"));
+
+        assertEquals("NJ", pick(candidates, "32", null, "114.114.114.114").getThirdShopId());
+    }
+
+    // ──────────────────────────── 什么算「能用的店」 ────────────────────────────
+
+    /**
+     * 只有 {@code SUCCESS} 且拿到了 {@code shop_id} 的店才算建成 —— PENDING 是「结果未知」,
+     * FAILED 是上游明确拒绝,两者拿去下单必然失败。末段是**正控**:把一家好店放进同一个候选集,
+     * 必须能挑到它,否则上面的三个 null 可能只是「这方法根本不挑任何东西」。
+     */
+    @Test
+    void pick_onlyConsidersSuccessfullyCreatedShops() {
+        List<HarShopEntity> unusable = List.of(
+                shop("FAILED", "32", "3201", HarShopCreateStatus.FAILED, "S1"),
+                shop("PENDING", "32", "3201", HarShopCreateStatus.PENDING, "S2"),
+                shop("NOID", "32", "3201", HarShopCreateStatus.SUCCESS, null),
+                shop("BLANKID", "32", "3201", HarShopCreateStatus.SUCCESS, "  "),
+                shop("NOSTATUS", "32", "3201", null, "S5"));
+        assertNull(pick(unusable, "32", "3201", "114.114.114.114"), "未建成的店不能拿去下单");
+
+        List<HarShopEntity> withOneGood = new ArrayList<>(unusable);
+        withOneGood.add(ok("GOOD", "32", "3201"));
+        assertEquals("GOOD", pick(withOneGood, "32", "3201", "114.114.114.114").getThirdShopId());
+    }
+
+    @Test
+    void pick_noCandidatesAtAll_picksNothing() {
+        assertNull(pick(List.of(), "32", "3201", "114.114.114.114"));
+    }
+
+    // ──────────────────────────── 同地区多店:按 IP 稳定散列 ────────────────────────────
+
+    private static List<HarShopEntity> fourShopsInJiangsu() {
+        return List.of(
+                ok("A", "32", "3201"),
+                ok("B", "32", "3201"),
+                ok("C", "32", "3201"),
+                ok("D", "32", "3201"));
+    }
+
+    /** 同一个顾客(同 IP)在缓存过期后重来一次,必须还是那一家 */
+    @Test
+    void pick_sameSeedIsStable() {
+        List<HarShopEntity> candidates = fourShopsInJiangsu();
+
+        for (int i = 0; i < 50; i++) {
+            String seed = "10.0." + (i / 250) + "." + (i % 250);
+            assertEquals(pick(candidates, "32", "3201", seed).getThirdShopId(),
+                    pick(candidates, "32", "3201", seed).getThirdShopId(),
+                    "同一 IP 必须稳定落到同一家,seed=" + seed);
+        }
+    }
+
+    /** 但不同 IP 要真的散开 —— 全落在一家就成了单点(这也是「按 IP 散列」而不是「固定第一家」的意义) */
+    @Test
+    void pick_distinctSeedsSpreadAcrossShops() {
+        List<HarShopEntity> candidates = fourShopsInJiangsu();
+        Set<String> chosen = new HashSet<>();
+
+        for (int i = 0; i < 400; i++) {
+            chosen.add(pick(candidates, "32", "3201", "10.0." + (i / 250) + "." + (i % 250)).getThirdShopId());
+        }
+
+        assertEquals(Set.of("A", "B", "C", "D"), chosen, "400 个不同 IP 应把所有店都用上");
+    }
+
+    /** seed 为空也要是个**总函数**:不抛,且稳定(正常路径不会走到这里,但别让它炸在顾客页上) */
+    @Test
+    void pick_blankSeedIsStillDeterministic() {
+        List<HarShopEntity> candidates = fourShopsInJiangsu();
+
+        HarShopEntity first = pick(candidates, "32", "3201", null);
+        assertNotNull(first);
+        assertEquals(first.getThirdShopId(), pick(candidates, "32", "3201", null).getThirdShopId());
+        assertEquals(first.getThirdShopId(), pick(candidates, "32", "3201", "  ").getThirdShopId());
+    }
+
+    // ──────────────────────────── 查询本身 ────────────────────────────
+
+    /**
+     * 查询必须**限定在这个商户下**:门店是挂在上游商户下的,跨商户的 shop_id 拿去下单必被拒。
+     * 这条钉住 SQL 谓词 —— 阶梯是纯函数、好测,真正容易被写漏的反而是这句 WHERE。
+     */
+    @Test
+    void pickForRegion_scopesTheQueryToTheMerchant() {
+        when(shopMapper.selectList(any())).thenReturn(List.of());
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<LambdaQueryWrapper<HarShopEntity>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+
+        assertNull(service.pickForRegion("M001", "32", "3201", "114.114.114.114"));
+
+        verify(shopMapper).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertTrue(sql.contains("merchant_code"), "实际: " + sql);
+    }
+
+    /** 商户号本身为空就别去查库了(空串会变成 merchant_code = '',查出一片空并静默拒收) */
+    @Test
+    void pickForRegion_blankMerchantCode_picksNothingWithoutQuerying() {
+        assertNull(service.pickForRegion("  ", "32", "3201", "114.114.114.114"));
+
+        verify(shopMapper, org.mockito.Mockito.never()).selectList(any());
+    }
+
+    /** 查出来的东西交给阶梯 —— 这条把两半接起来(上面各测一半,接错也看不出来) */
+    @Test
+    void pickForRegion_picksFromWhatTheQueryReturned() {
+        when(shopMapper.selectList(any())).thenReturn(List.of(
+                ok("NJ", "32", "3201"),
+                ok("HZ", "33", "3301")));
+
+        assertEquals("NJ", service.pickForRegion("M001", "32", "3201", "114.114.114.114").getThirdShopId());
+    }
+}

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

@@ -14,6 +14,7 @@ import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
 import com.payment.platform.module.payment.har.entity.HarShopEntity;
 import com.payment.platform.module.payment.har.enums.HarShopCreateStatus;
 import com.payment.platform.module.payment.har.mapper.HarShopMapper;
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -43,7 +44,9 @@ class HarShopServiceTest {
 
     @BeforeEach
     void setUp() {
-        service = new HarShopService(shopMapper, merchantService, harClient);
+        // 地区目录用**真家伙**(纯内存、无依赖,读的是打进包里的快照):
+        // 门店创建时它要校验省市编码,mock 掉就等于把这道校验从每条 create 用例里摘掉了。
+        service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog());
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
         // (否则 getSqlSegment() 抛 MybatisPlusException: can not find lambda cache for this entity。
         //   同款引导见 LsyCallbackServiceTest / AlipayBatchPayServiceTest。)
@@ -56,6 +59,9 @@ class HarShopServiceTest {
         dto.setEnterpriseId("E1");
         dto.setThirdShopId("SHOP001");
         dto.setShopName("测试门店");
+        // 省市编码自 2026-10-08 起上游必填,本地也会对目录校验 —— 缺了走不到调上游那一步
+        dto.setProvinceCode("32");
+        dto.setCityCode("3201");
         return dto;
     }
 
@@ -214,6 +220,73 @@ class HarShopServiceTest {
         assertEquals("测试门店", captor.getValue().getName());
     }
 
+    // ==================== 门店地区(2026-10-08 规范新增,异地收款风控) ====================
+
+    /**
+     * ★ 两个地区编码都要发给上游,**且必须是上游的字段名**({@code province_code} / {@code city_code})。
+     *
+     * <p>这条断言报文原文而不是只看 getter:序列化名写错(漏 {@code @JsonProperty})时,
+     * getter 全绿而线上发出去的是 {@code provinceCode},上游只会回一句参数错误 ——
+     * 本地从字段上完全看不出来。同时钉住落库的那两列:收款链接按地区挑店读的正是它们。
+     */
+    @Test
+    void create_forwardsRegionCodesUpstreamWithUpstreamFieldNames() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any()))
+                .thenReturn(response("10000", "{\"shop_id\":\"S1\"}"));
+
+        service.create(dto(), "E1");
+
+        ArgumentCaptor<HarShopCreateRequest> captor = ArgumentCaptor.forClass(HarShopCreateRequest.class);
+        verify(harClient).createShop(captor.capture());
+        assertEquals("32", captor.getValue().getProvinceCode());
+        assertEquals("3201", captor.getValue().getCityCode());
+
+        String biz = HarClient.toBizContent(captor.getValue());
+        assertTrue(biz.contains("\"province_code\":\"32\""), "实际报文: " + biz);
+        assertTrue(biz.contains("\"city_code\":\"3201\""), "实际报文: " + biz);
+
+        ArgumentCaptor<HarShopEntity> row = ArgumentCaptor.forClass(HarShopEntity.class);
+        verify(shopMapper).insert(row.capture());
+        assertEquals("32", row.getValue().getProvinceCode());
+        assertEquals("3201", row.getValue().getCityCode());
+    }
+
+    /**
+     * 编码对不上目录 ⇒ 这家店建出来在收款链接里**永远挑不中**,而上游没有门店更新接口
+     * (见 HarShopEntity javadoc)事后补不上,且全程没有任何报错 —— 只能在建的那一刻拦住。
+     *
+     * <p>这条用例不 stub 任何东西:校验若不在**最前面**(先查重、先解析商户),
+     * 它就会先抛别的错,或去动 mapper/上游,断言立刻红。
+     */
+    @Test
+    void create_rejectsCityCodeThatIsNotInTheGivenProvince() {
+        HarShopCreateDTO dto = dto();
+        dto.setCityCode("3301");   // 杭州,挂在江苏名下
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.create(dto, "E1"));
+
+        assertTrue(e.getMessage() != null && e.getMessage().contains("城市"),
+                "提示要让运营看得懂,实际: " + e.getMessage());
+        verify(harClient, never()).createShop(any());
+        verify(shopMapper, never()).insert(any());
+    }
+
+    @Test
+    void create_rejectsUnknownProvinceCode() {
+        HarShopCreateDTO dto = dto();
+        dto.setProvinceCode("99");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.create(dto, "E1"));
+
+        assertTrue(e.getMessage() != null && e.getMessage().contains("省"),
+                "实际: " + e.getMessage());
+        verify(harClient, never()).createShop(any());
+        verify(shopMapper, never()).insert(any());
+    }
+
     @Test
     void create_enterpriseIdFromDto_whenNotPassedIn() {
         when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());

+ 260 - 0
java/src/test/java/com/payment/platform/module/payment/har/util/HarRegionCatalogTest.java

@@ -0,0 +1,260 @@
+package com.payment.platform.module.payment.har.util;
+
+import com.payment.platform.module.payment.har.util.HarRegionCatalog.City;
+import com.payment.platform.module.payment.har.util.HarRegionCatalog.Province;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 地区目录(省市快照)与繁简归一化的守卫。
+ *
+ * <p>目录数据是对外部数据集的**快照**(来源与 sha256 记在 {@link HarRegionCatalog} 的类 javadoc),
+ * 生成脚本不进版本库 ⇒ 下面这些断言是唯一能证明「快照仍是它该有的样子」的东西:
+ * 条数、编码前缀、同省内归一化后不重名,以及繁简表**不会反过来改坏数据集自己的字**。
+ *
+ * <p>末两条用例的输入不是我编的,是 2026-10-08 实测 ipwho.is 回包里抄下来的
+ * ({@code 西藏自治區} 那条繁体「區」、{@code 渝中區} 那条区级名、{@code 昌吉回族自治州} 那条自治州名)。
+ */
+class HarRegionCatalogTest {
+
+    /** 数据集快照应为 31 个省级(不含港澳台 —— ipwho.is 对它们的 country 不是「中国」,见解析器) */
+    private static final int PROVINCE_COUNT = 31;
+
+    /** 数据集快照应为 342 个地级 */
+    private static final int CITY_COUNT = 342;
+
+    private final HarRegionCatalog catalog = new HarRegionCatalog();
+
+    // ──────────────────────────── 快照完整性 ────────────────────────────
+
+    @Test
+    void provinces_snapshotIs31_uniqueTwoDigitCodes() {
+        List<Province> ps = catalog.provinces();
+
+        assertEquals(PROVINCE_COUNT, ps.size(), "省级条数变了 ⇒ 快照被换过,下游匹配的分支要一并复看");
+        assertEquals(PROVINCE_COUNT, ps.stream().map(Province::code).distinct().count(), "省级编码有重复");
+        assertEquals(PROVINCE_COUNT, ps.stream().map(Province::name).distinct().count(), "省级名称有重复");
+        assertTrue(ps.stream().allMatch(p -> p.code().matches("\\d{2}")), "省级编码必须是 2 位数字");
+        assertTrue(ps.stream().allMatch(p -> !p.name().isBlank()), "省级名称不能为空");
+    }
+
+    @Test
+    void cities_snapshotIs342_codesPrefixMatchProvince() {
+        List<City> all = catalog.provinces().stream()
+                .flatMap(p -> catalog.cities(p.code()).stream())
+                .toList();
+
+        assertEquals(CITY_COUNT, all.size(), "地级条数变了 ⇒ 快照被换过");
+        assertEquals(CITY_COUNT, all.stream().map(City::code).distinct().count(), "地级编码有重复");
+        assertTrue(all.stream().allMatch(c -> c.code().matches("\\d{4}")), "地级编码必须是 4 位数字");
+        assertTrue(all.stream().allMatch(c -> c.code().startsWith(c.provinceCode())),
+                "地级编码必须以省码开头 —— 前缀断裂会让「按省回退」捞到别的省的门店");
+        assertTrue(all.stream().allMatch(c -> catalog.provinces().stream()
+                        .anyMatch(p -> p.code().equals(c.provinceCode()))),
+                "地级指向了不存在的省");
+    }
+
+    /**
+     * 归一化会把后缀与族名抹掉(拉萨市→拉萨、昌吉回族自治州→昌吉、广西壮族自治区→广西),
+     * 同一个省内出现两个抹完一样的名字 ⇒ 按名匹配就成了掷硬币。
+     */
+    @Test
+    void cityNames_doNotCollideWithinProvince_afterNormalization() {
+        for (Province p : catalog.provinces()) {
+            Set<String> normalized = catalog.cities(p.code()).stream()
+                    .map(c -> catalog.normalize(c.name()))
+                    .collect(Collectors.toSet());
+            assertEquals(catalog.cities(p.code()).size(), normalized.size(),
+                    "省内出现抹平后同名的地级: " + p.name());
+        }
+    }
+
+    /**
+     * 直辖市的城市下拉不能出现「市辖区」(GB/T 2260 里 1101/1201/3101/5001 的名字),
+     * 顾客与商户读到的应该是省名。
+     */
+    @Test
+    void cityLabels_neverExposeTheGbshiXiaQuPlaceholder() {
+        List<String> bad = catalog.provinces().stream()
+                .flatMap(p -> catalog.cities(p.code()).stream())
+                .filter(c -> "市辖区".equals(c.name()) || "县".equals(c.name()))
+                .map(City::code)
+                .toList();
+
+        assertTrue(bad.isEmpty(), "直辖市占位名漏到展示层: " + bad);
+    }
+
+    // ──────────────────────────── 繁简表 ────────────────────────────
+
+    /**
+     * ★ 本批最要紧的一条守卫:繁简表是「繁体 → 简体」,但数据集自己的字里
+     * **恰好有字是别的字的繁体**(实测:{@code 沈} 是 {@code 沉} 的繁体变体,
+     * 而「沈阳市」在数据集里)。这类键一旦进表,归一化会把数据集自己的名字改坏
+     * (沈阳市 → 沉阳市),匹配全线失守而且**没有任何报错**。
+     * 生成脚本按此规则剔键,这里把它钉住。
+     */
+    @Test
+    void tsTable_neverRewritesAnyCharacterUsedByTheDataset() {
+        Set<Character> datasetChars = catalog.provinces().stream()
+                .flatMap(p -> (p.name() + catalog.cities(p.code()).stream()
+                        .map(City::name).collect(Collectors.joining())).chars().mapToObj(c -> (char) c))
+                .collect(Collectors.toSet());
+
+        Set<Character> offending = catalog.tsTable().keySet().stream()
+                .filter(datasetChars::contains)
+                .collect(Collectors.toSet());
+
+        assertTrue(offending.isEmpty(), "繁简表里混进了数据集自己的字,会把名字改坏: " + offending);
+    }
+
+    @Test
+    void tsTable_valuesAreDatasetCharactersAndKeysDifferFromValues() {
+        Set<Character> datasetChars = catalog.provinces().stream()
+                .flatMap(p -> (p.name() + catalog.cities(p.code()).stream()
+                        .map(City::name).collect(Collectors.joining())).chars().mapToObj(c -> (char) c))
+                .collect(Collectors.toSet());
+
+        assertFalse(catalog.tsTable().isEmpty(), "繁简表不能是空的 —— 空表等于把繁体名一律判成未命中");
+        assertEquals(166, catalog.tsTable().size(),
+                "繁简表条数变了 ⇒ 派生口径或上游源表换过,去核 logs/_har-region-gen.py 与资源里的 source 块");
+        assertTrue(catalog.tsTable().entrySet().stream().allMatch(e -> datasetChars.contains(e.getValue())),
+                "表里指向了数据集根本不用的字 ⇒ 派生范围不对");
+        assertTrue(catalog.tsTable().entrySet().stream().allMatch(e -> !e.getKey().equals(e.getValue())),
+                "恒等映射是噪音");
+    }
+
+    @Test
+    void normalize_isIdempotentOnEveryDatasetName() {
+        for (Province p : catalog.provinces()) {
+            String once = catalog.normalize(p.name());
+            assertEquals(once, catalog.normalize(once), "归一化不幂等: " + p.name());
+            for (City c : catalog.cities(p.code())) {
+                String o = catalog.normalize(c.name());
+                assertEquals(o, catalog.normalize(o), "归一化不幂等: " + c.name());
+            }
+        }
+    }
+
+    // ──────────────────────────── 按名匹配 ────────────────────────────
+
+    /** 输入全部抄自 2026-10-08 的 ipwho.is 实测回包(lang=zh-CN 的 region 字段) */
+    @Test
+    void matchProvince_handlesTraditionalAndSuffixVariants() {
+        assertEquals("54", provinceCode("西藏自治區"), "实测回包里的繁体「區」");
+        assertEquals("54", provinceCode("西藏自治区"));
+        assertEquals("65", provinceCode("新疆维吾尔自治区"));
+        assertEquals("45", provinceCode("广西壮族自治区"));
+        assertEquals("64", provinceCode("宁夏回族自治区"));
+        assertEquals("15", provinceCode("内蒙古自治区"));
+        assertEquals("15", provinceCode("内蒙古"));
+        assertEquals("11", provinceCode("北京市"));
+        assertEquals("11", provinceCode("北京"));
+        assertEquals("32", provinceCode("江苏省"));
+    }
+
+    @Test
+    void matchProvince_unknownBlankOrNonMainland_returnsEmpty() {
+        assertTrue(catalog.matchProvince(null).isEmpty());
+        assertTrue(catalog.matchProvince("").isEmpty());
+        assertTrue(catalog.matchProvince("  ").isEmpty());
+        assertTrue(catalog.matchProvince("Tokyo").isEmpty());
+        assertTrue(catalog.matchProvince("香港特别行政区").isEmpty(), "数据集只含 31 个省级,港澳台不在内");
+        assertTrue(catalog.matchProvince("渝中區").isEmpty(), "区级名不是省级名");
+    }
+
+    @Test
+    void matchCity_isScopedToTheGivenProvince() {
+        assertEquals("3201", cityCode("32", "南京"));
+        assertEquals("3201", cityCode("32", "南京市"));
+        assertEquals("6523", cityCode("65", "昌吉回族自治州"));
+        assertEquals("5401", cityCode("54", "拉萨"));
+        assertEquals("5401", cityCode("54", "拉薩"), "同一条实测回包里 city 是简体,但别的字段出现过繁体");
+        assertEquals("3202", cityCode("32", "無錫市"), "繁体整串也要能命中");
+    }
+
+    @Test
+    void matchCity_districtLevelOrForeignName_returnsEmpty() {
+        assertTrue(catalog.matchCity("50", "渝中區").isEmpty(), "实测回包里重庆市回的是区级名");
+        assertTrue(catalog.matchCity("11", "").isEmpty());
+        assertTrue(catalog.matchCity("11", null).isEmpty());
+        assertTrue(catalog.matchCity("32", "Tokyo").isEmpty());
+        assertTrue(catalog.matchCity("99", "南京").isEmpty(), "省码不存在");
+    }
+
+    /**
+     * 直辖市的 1101/1201/3101 展示成省名,于是「北京市 + 北京」能命中市一级。
+     *
+     * <p>★ 重庆(50)**有意不在**这条用例里:实测它的回包给的是区级名(渝中區),
+     * 走的是「市级未命中 ⇒ 回退全省」那一步;这里不写我没量到过的行为。
+     */
+    @Test
+    void municipalities_matchAtCityLevelViaTheProvinceName() {
+        assertEquals("1101", cityCode("11", "北京"));
+        assertEquals("3101", cityCode("31", "上海"));
+        assertEquals("1201", cityCode("12", "天津"));
+    }
+
+    // ──────────────────────────── 按编码查(门店创建校验用) ────────────────────────────
+
+    /**
+     * 门店创建时要拿它校验提交上来的编码:**编码写错的门店在收款链接里永远挑不中**,
+     * 而上游没有门店查询/更新接口({@code HarShopEntity} 的 javadoc)⇒ 事后补不上,
+     * 只能在建的那一刻拦住。
+     *
+     * <p>与 {@link #matchCity_isScopedToTheGivenProvince()} 同一条口径:**限定在给定省内**。
+     */
+    @Test
+    void cityByCode_isScopedToTheGivenProvince() {
+        // 与按名匹配互相对照:两条路必须指向同一条记录(同一条自证不算数)
+        assertEquals(catalog.matchCity("32", "南京").orElseThrow(),
+                catalog.cityByCode("32", "3201").orElseThrow());
+
+        assertTrue(catalog.cityByCode("32", "3301").isEmpty(), "3301 是浙江的市码,不该挂在江苏名下");
+        assertTrue(catalog.cityByCode("99", "3201").isEmpty(), "省码不存在");
+        assertTrue(catalog.cityByCode("32", "320").isEmpty(), "位数不对");
+        assertTrue(catalog.cityByCode("32", "32").isEmpty());
+        assertTrue(catalog.cityByCode("32", null).isEmpty());
+        assertTrue(catalog.cityByCode(null, "3201").isEmpty());
+        assertTrue(catalog.cityByCode("32", "  ").isEmpty());
+        // 正控:同一个量具对**该在**的码必须命中,否则上面那串空可能只是它永远查不到东西
+        assertTrue(catalog.cityByCode("32", "3201").isPresent());
+    }
+
+    /** 完备性:数据集里每一个市码都要按码查得到,且查回来还是它自己 */
+    @Test
+    void cityByCode_findsEveryDatasetCity() {
+        int n = 0;
+        for (Province p : catalog.provinces()) {
+            for (City c : catalog.cities(p.code())) {
+                City found = catalog.cityByCode(p.code(), c.code()).orElse(null);
+                assertNotNull(found, "按码查不到: " + c.code() + " (" + c.name() + ")");
+                assertEquals(c.name(), found.name());
+                assertEquals(c.provinceCode(), found.provinceCode());
+                n++;
+            }
+        }
+        assertEquals(CITY_COUNT, n, "遍历条数与快照不一致 ⇒ 这条完备性没覆盖全");
+    }
+
+    // ──────────────────────────── 小工具 ────────────────────────────
+
+    private String provinceCode(String raw) {
+        Province p = catalog.matchProvince(raw).orElse(null);
+        assertNotNull(p, "应命中省级: " + raw);
+        return p.code();
+    }
+
+    private String cityCode(String provinceCode, String raw) {
+        City c = catalog.matchCity(provinceCode, raw).orElse(null);
+        assertNotNull(c, "应命中地级: " + provinceCode + "/" + raw);
+        return c.code();
+    }
+}

BIN
支付宝支付接口接入规范261008(1).docx