1
0

12 Коммиты ed24480f4a ... f09e9cce37

Автор SHA1 Сообщение Дата
  alphaH f09e9cce37 docs(交付): 门店地区交付说明 —— 两处时间改成量出来的(12:47 是我写的,实际 12:51:21) 1 неделя назад
  alphaH d0227fabd6 docs(交付): 门店地区交付说明 —— 前端读数补第三次构建与订正后 ts:check 1 неделя назад
  alphaH 262e452f04 docs(交付): 门店地区交付说明 —— 提交数不写死(本说明自己在数里,自指) 1 неделя назад
  alphaH 432872c6ca docs(交付): 门店地区交付说明 —— 提交数改成 git 量出来的 8(含本说明自身两刀) 1 неделя назад
  alphaH a6bdcc4e11 docs(交付): 门店地区交付说明 —— 补 400 订正与产物重打读数 1 неделя назад
  alphaH 300a27f9dd fix(汇安融): 地区编码填错统一返回 400 —— 与只读端点同一个码 1 неделя назад
  alphaH 0a07a456fd docs(交付): 门店地区 + 按顾客 IP 挑店 交付说明 —— 四步动作与五项未验 1 неделя назад
  alphaH ccc34414b9 docs(汇安融): 修 markdown 空行 —— 引用块与分隔线之间要空一行 1 неделя назад
  alphaH 8e352e7ee9 feat(汇安融): 门店表单录地区(四)—— 前端省市选择器 + 开放 API 文档同步 1 неделя назад
  alphaH c47b0c63aa feat(汇安融): 门店省市目录只读端点 + 建表脚本(三)—— 给运营一个录地区的入口 1 неделя назад
  alphaH 32384b2ba2 feat(汇安融): 收款链接按顾客 IP 挑门店(二)—— 结账腿接线 1 неделя назад
  alphaH b1c8f6fb51 feat(汇安融): 门店地区与按 IP 挑店(一)—— 目录快照 + IP 归属地解析 + 挑店阶梯 1 неделя назад
24 измененных файлов с 4614 добавлено и 21 удалено
  1. 124 0
      .claude/plan/2026-10-08-har-shop-region-交付说明.md
  2. 64 0
      frontend/src/api/module_payment/har.ts
  3. 12 0
      frontend/src/views/module_payment/apikey/docs/har-openapi.md
  4. 136 1
      frontend/src/views/module_payment/har/shop/index.vue
  5. 13 0
      java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarShopCreateRequest.java
  6. 12 0
      java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java
  7. 119 3
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarCheckoutController.java
  8. 90 0
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarRegionController.java
  9. 39 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarRegionVO.java
  10. 16 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopCreateDTO.java
  11. 6 0
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopVO.java
  12. 15 0
      java/src/main/java/com/payment/platform/module/payment/har/entity/HarShopEntity.java
  13. 230 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarIpRegionResolver.java
  14. 132 0
      java/src/main/java/com/payment/platform/module/payment/har/service/HarShopService.java
  15. 267 0
      java/src/main/java/com/payment/platform/module/payment/har/util/HarRegionCatalog.java
  16. 56 0
      java/src/main/resources/db/har-shop-region.sql
  17. 2019 0
      java/src/main/resources/har-region-cn.json
  18. 244 16
      java/src/test/java/com/payment/platform/module/payment/har/controller/HarCheckoutControllerTest.java
  19. 177 0
      java/src/test/java/com/payment/platform/module/payment/har/controller/HarRegionControllerTest.java
  20. 262 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarIpRegionResolverTest.java
  21. 242 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarShopRegionPickTest.java
  22. 79 1
      java/src/test/java/com/payment/platform/module/payment/har/service/HarShopServiceTest.java
  23. 260 0
      java/src/test/java/com/payment/platform/module/payment/har/util/HarRegionCatalogTest.java
  24. BIN
      支付宝支付接口接入规范261008(1).docx

+ 124 - 0
.claude/plan/2026-10-08-har-shop-region-交付说明.md

@@ -0,0 +1,124 @@
+# 汇安融「门店地区 + 按顾客 IP 挑店」交付说明(2026-10-08)
+
+需求原文(用户,指向《支付宝支付接口接入规范261008(1).docx》):
+「门店新增了地区相关的字段,需要接上,因为支付宝异地收款会有风控,我们的 checkout 也要做一个调整,
+在调起支付时要根据请求者 ip 自动选择对应地区的门店 id,这样才不会风控」
+
+设计裁定(本批开工前已与你确认,未改):IP 归属地取 ipwho.is(进程内缓存,失败⇒空);
+粒度 = 城市精确匹配、省作回退;匹配不上**拒收**(顾客语,不打上游、不落单);同城多店按 IP
+稳定散列挑一家;城市未匹配则该省随机一家。
+
+★ 这一批**没有写计划文件**(前几批是 `.claude/plan/<日期>-<主题>.md`),需求以 docx + 上述裁定
+为准,落地过程与读数都在 `logs/` 下的日志里 —— 这点与既有批次不同,先说明。
+
+## 一、提交清单(全部**未 push**)
+
+`origin/java` 停在 `ed24480f`。本地领先的提交 = **下表 6 个功能/文档刀 + 本说明自身的提交**
+(`0a07a456` 首次提交,以及本文件每次订正各一刀)—— 不写死总数:本说明自己也在数里,
+写完再提交一次就 +1(要读数就现量 `git rev-list --count origin/java..java`)。
+
+| 提交 | 内容 |
+|---|---|
+| `b1c8f6fb` | (一)目录快照 `har-region-cn.json`(31 省 / 342 地级 / 166 繁简对)+ `HarRegionCatalog` + `HarIpRegionResolver` + `HarShopService#pickForRegion` + 门店/创建请求带地区 |
+| `32384b2b` | (二)结账腿接线:`HarCheckoutController` 按顾客 IP 挑店填 `third_shop_code`;挑不出来 400 拒收(两种文案);`clientIp` 四个头 + unknown 兜底 |
+| `c47b0c63` | (三)省市目录只读端点 `GET /payment/har/region/provinces|cities` + `db/har-shop-region.sql` |
+| `8e352e7e` | (四)前端:门店表单省→市选择器、列表「地区」列、详情一行;开放 API 文档 §10.1 同步 |
+| `ccc34414` | 文档 markdown 空行修正(引用块贴着 `---` 会被当成懒惰续行) |
+| `300a27f9` | (五)编码填错统一 400:`HarShopService#requireKnownCity` 两处单参 `BusinessException`(默认码 500)改显式 400;两条地区拒收用例补码断言(先红后绿);开放文档 §10.1 补码值 |
+
+★ **`b1c8f6fb` 里夹带了一份 `支付宝支付接口接入规范261008(1).docx`(87512 字节)** ——
+那是开工时会话开始前就已经 `git add` 进索引的文件,提交时没有指定路径,于是跟着这一刀进去了。
+**我没有改写历史**(改写会动到你已经能看到的提交)。**你的裁定是保持现状**(2026-10-08),
+本批不再动它:留着无功能影响,摘掉需要 rebase/改写。后续提交都改成
+「显式路径 `git add` + `git commit -F <msg> -- <显式路径>`」。
+
+## 二、验证读数(本机实测,不是推断)
+
+- 整包 `mvn test`:**1212 条,0 失败 0 错误 0 跳过,BUILD SUCCESS**
+  (★ 两条读数:第一条 12:44:08 在 `300a27f9` 之前;400 订正后 **12:51:21 复跑同数**
+  = 1212/0/0/0,`Total time: 21.430 s`,日志 `logs/_mvn-full-region-code-400.txt` 的 mtime;
+  `HarCheckoutControllerTest` 24 + `HarRegionControllerTest` 5 都在内)
+- 两向变异(每条都先确认变异体生效,还原后 sha256 复核回基线):
+
+| 变异体 | 结果 |
+|---|---|
+| 结账腿:4 个(挑到不填 `third_shop_code` / 两句拒收文案合并 / 不取 XFF 第一跳 / 去掉 `unknown` 兜底) | 预测红集 **6**,实测恰为那 6 条,其余 18 条作正对照仍绿;还原后 sha256 与变异前一致(24/0) |
+| 目录端点:3 个(省码不校验 / 用空表代替 400 / 权限串只留 `:create`) | 预测红集 **3**,实测 `cities_returnsOnlyThatProvincesCities`、`cities_unknownProvinceCode_400_notAnEmptyList`、`contract_pathAndPermissionAreTheOnesTheFrontendCalls`;2 条对照仍绿;还原后 sha256 一致(5/0) |
+
+- 前端(**本仓无测试框架**,`ts:check` 基线本就非零 ⇒ 只能同机差分):
+  - `pnpm ts:check`:基线 **289** 条 error TS(exit 2)→ 改动后 **289** 条,**逐条集合比对
+    新增 0、消失 0**;★ 400 订正(只动一行 md)之后再量一次仍是 **289**(exit 2,
+    `logs/_tscheck-region-code-400.txt`)—— 文档经 `?raw` 进来,不进类型检查
+  - `pnpm build`:exit 0(12:42 / 12:46 / ★ 400 订正后那次 56.39s、日志 mtime 12:53:05,共三次);
+    当时那次已回读产物确认新代码真进了包:
+    `dist/js/index.x_9MozyF.js` 含 `region/provinces`、`dist/js/index.SNmFzXAj.js` 含文档新句
+    「城市编码不属于该省」、`province_code` 出现在 3 个 chunk(★ 这三个 chunk 名只属于当时那一版,
+    12:53 那版全变了 —— 见上「产物指纹」)(**注意**:一开始我按 `dist/assets/*.js`
+    搜,3 个都查成 0 —— 是本仓产物在 `dist/js/`,是**检索式错**不是文件缺失,换路径后同一条搜索命中);
+  - Element Plus 用法对着**装好的包**核过(2.14.0 `select.mjs`):`loading: Boolean`
+    「whether Select is loading data from server」、`disabled: Boolean` —— 不是凭记忆。
+
+- 产物指纹(★ 下面这版是**要部署的**,12:51/12:53 在 `300a27f9` 之后重打):
+  - `java/target/payment-platform-1.0.0.jar`,143,146,853 字节,**sha256 `b6b64c8ad8455d4e1e0639d7d8ad04ea9db347292ec75bb81cb384213379068d`**,
+    重打于 12:51:49;开包验的是产物本身:从 jar 里取 `HarShopService.class` 反汇编
+    (`javap -p -c`),两处拒收都是 `sipush 400` → `BusinessException.<init>:(ILjava/lang/String;)V`
+  - `frontend/dist.zip`,9,375,214 字节,**sha256 `80038a66ef9dd73678ec5d9158f66763eef6f4451feb9f3fd9b92d50a2468fa6`**,
+    12:53:49;281 个条目 = 277 文件 + 4 个目录条目(`dist/`、`dist/css/`、`dist/img/`、`dist/js/`,
+    与上一版同形:**全为正斜杠**、`testzip()` 无坏成员)
+  - 这两版之前的旧件已被取代,别再照旧指纹核产物:
+    ~~jar `977da280…`(143,146,829 字节,12:45:02)、`dist.zip` `dd4cf524…`(9,375,221 字节)~~
+  - 文档改了一行 ⇒ dist 里 **103 个 chunk 的文件名哈希跟着变了**(Rollup 的 `[hash]` 含依赖哈希,
+    一处内容改动脉冲式传播),条目数没变(前后各 277 个文件)。两向读数:旧包里只有旧句
+    `不属于该省的编码会被拒`(在 `js/index.SNmFzXAj.js`),新包里只有新句 `编码填错一律`
+    (在 `js/index.ruVK6GJ7.js`),互不相见 —— 不是「重建后一律变」的空话
+
+## 三、部署要求(按顺序,第 1 步不做就没有后面)
+
+1. **先执行 `java/src/main/resources/db/har-shop-region.sql`**(本工程未接 Flyway,只能你执行)。
+   ★ 反了不是静默降级而是**直接报错**:实体带上 `province_code`/`city_code` 后,门店列表与
+   挑店的每一句 SELECT 都会取这两列 ⇒ 门店列表 500,且**每一笔收款链接下单都失败**。
+2. **重新部署**:上面的 jar + `frontend/dist.zip`。
+3. **不需要新菜单 SQL、不需要重登**:目录端点复用门店既有的
+   `module_payment:har:shop:list` / `:create` 权限串(`@perm.hasAny` 两条),菜单不动。
+4. **跑脚本末尾那三条只读核对**,尤其第 ③ 条 —— 它给出的数字就是**要重开的店数**:
+   `province_code`/`city_code` 为空的门店(2026-10-08 之前建的)在挑店时必然落选。
+   上游没有门店更新接口,**补不上**,要收款只能换新的 `third_shop_id` 重开。
+5. 验收路径:门店管理 → 创建门店 → 省/市两级下拉能出目录 → 提交 → 列表「地区」列显示
+   「江苏省 / 南京市」→ 从**另一个地区**的网络打开该商户的收款链接 → 应当拒收
+   (「无法确定您当前所在地区…」或「收款方暂未开通您所在地区的收款…」),而不是跑到异地门店。
+
+## 四、未验项(如实列,别当已验)
+
+1. **前端页面没有执行级验证**:本仓前端没有测试框架、也没有能起真 SFC 的台子,页面还要登录态
+   才能打开。「选择器真的拉到 31 个省」「换省真的换了市」「列表把码显示成了名」这几步
+   **一次都没被真跑过** —— 我只能给到「类型零新增 + 构建通过 + 产物里确有这段代码」。
+2. **`pay_url` 仍然没有被真机扫过**(上一批就挂着的问题,本批没变);挑店在**真实顾客 IP**
+   下的表现(真挑到哪个地区的哪家店)没有端到端跑过,只跑到「挑店逻辑 + 端口」这一层。
+3. **上游是否真的按地区风控**未知:本批保证的是「我们把对的码发上去、按地区挑店」,
+   上游收了这两个码之后怎么用,没有可观测的读数。
+4. **X-Forwarded-For 可伪造**:除非前置代理覆写它(写在 `HarCheckoutController` 的类 javadoc
+   「已知的边界」里)。后果是把顾客导到收款方自己的**另一家门店**,不涉资金与租户边界,
+   要堵得在网关上 —— 本批不动。
+5. **`HarCheckoutController` 三个偏差未改**(都在代码注释里标了,属规范与现状不一致,
+   不是本次引入):`shopName` 我方 `@Size(128)` vs 规范 varchar(16);`thirdShopCode` 32 vs 30;
+   实体列宽按规范 16 建。
+
+## 五、两件拍板(你已裁定:① 我解决、② 保持现状)
+
+1. **同类错误码两处不一致 —— 已按裁定修掉(`300a27f9`)**:编码不认识时,
+   `HarRegionController` 返回 `code=400`(两参构造),
+   而 `HarShopService#requireKnownCity` 返回 **`code=500`**(单参 `BusinessException` 的默认码)。
+   后者是对外开放 API 也会走到的路径 ⇒ 外部调用方传错码会看到「服务器错误」。
+   现在两处都是显式 400,**文案一字未动**。读数:给两条地区拒收用例各加一条
+   `assertEquals(400, e.getCode())` 后实测红(`expected: <400> but was: <500>`,两条都红,
+   同类其余 16 条绿)→ 改完该件 18/18 绿 → 整包 **1212/0/0/0**(21.4s)。
+   开放文档 §10.1 那句从「只写 msg 形状、不写码值」改成写死 `code=400`(按上面的实测口径)。
+2. **`b1c8f6fb` 里的 docx:保持现状**(按裁定未动,历史未改写)。
+3. 顺带说明(**不在本次裁定范围,仍未动**):**`third_shop_id` 重复仍是 `code=500`**
+   —— 它与上面同类,但只有一处入口、且开放文档第 82 行已按 500 写给外部调用方,
+   改它等于动一条既有对外契约。要统一请单独说。
+
+另:`probe` 结论提醒 —— 顾客端拒收文案能上屏,是因为 `GlobalExceptionHandler` 对
+`BusinessException` **不带 `@ResponseStatus`**(HTTP 200 + body `code`),顾客页自建实例
+读的是 `res.data.msg`;如果哪天有人把它改成真 4xx/5xx,顾客页会 fallback 成
+「网络异常,请检查网络后重试」,那句「你所在地区未开通」就再也到不了顾客眼前。

+ 64 - 0
frontend/src/api/module_payment/har.ts

@@ -77,6 +77,16 @@ export interface HarShopVO {
   /** 商户自己的门店编码,幂等键 */
   third_shop_id: string;
   shop_name?: string;
+  /**
+   * GB/T 2260 两位省码(`32`=江苏省)。
+   *
+   * ★ 可空**只对 2026-10-08 之前建的门店**成立:上游没有门店更新接口,那批店补不上,
+   *   挑店时会被当成「地区不匹配」落选 ⇒ 页面必须把「未填」显示成未填,
+   *   不能看着像有地区(见 shop/index.vue 的 regionLabel)。
+   */
+  province_code?: string | null;
+  /** GB/T 2260 四位市码(`3201`=南京市),见 province_code */
+  city_code?: string | null;
   /** 上游返回的平台门店编号,创建成功才有值 */
   shop_id?: string;
   /** PENDING=结果未知待人工核实 / SUCCESS=上游已确认 / FAILED=上游明确拒绝 */
@@ -92,6 +102,13 @@ export interface HarShopVO {
 export interface HarShopCreateParams {
   third_shop_id: string;
   shop_name: string;
+  /**
+   * 省份编码,**必填**(规范 261008 5.5)。取值只能来自 `regionProvinces()`
+   * —— 前端不许自己存一份省市表:编码是挑店比对用的键,与后端目录必须同源。
+   */
+  province_code: string;
+  /** 市编码,**必填**,且必须属于上面那个省(后端按省查表校验) */
+  city_code: string;
   /** 选填。为空 ⇒ 走**租户级**商户映射(enterprise_id IS NULL 的那一行) */
   enterprise_id?: string;
   /**
@@ -103,6 +120,20 @@ export interface HarShopCreateParams {
   merchant_code?: string;
 }
 
+/**
+ * 一条地区记录(后端 HarRegionVO)—— 省与地级共用这个形状:码 + 名字。
+ *
+ * ★ `code` 是提交回 `HarShopCreateParams` 的那一格,`name` 只用于显示。
+ * ★ 这份目录同时是**顾客 IP 挑店**的比对表 ⇒ 页面选出来的码就是路由用的码。
+ *   不要在前端另存省市表,也不要对码做加工(补零、截断、拼接)。
+ */
+export interface HarRegionVO {
+  /** GB/T 2260:省两位(`32`),地级四位(`3201`) */
+  code: string;
+  /** 展示名。直辖市的地级是占位名换成的省名(重庆给出 5001/5002 两个) */
+  name: string;
+}
+
 /** 订单(后端 HarOrderVO)— 状态直接采用上游枚举,不做本地转义 */
 export interface HarOrderVO {
   id?: string;
@@ -358,6 +389,39 @@ const HarAPI = {
     });
   },
 
+  // ---------- 地区目录(门店表单的省市选择器)----------
+
+  /**
+   * 省级列表(31 个,港澳台不在内)。
+   *
+   * ★ 目录在后端只有一份(`HarRegionCatalog`,同一份也用于按顾客 IP 挑店),
+   *   所以**不要**在前端另存一份省市表:两份表迟早会分叉,而分叉的后果是
+   *   运营选出来的门店「永远挑不中」,上游又没有门店更新接口,补不上。
+   *
+   * ★ 权限串复用门店的 `list`/`create`(`@perm.hasAny(...)`)⇒ 不需要新的菜单 SQL。
+   */
+  regionProvinces() {
+    return request<ApiResponse<HarRegionVO[]>>({
+      url: `${API_PATH}/region/provinces`,
+      method: "get",
+    });
+  },
+
+  /**
+   * 某省的地级列表。
+   *
+   * ★ 参数名是下划线 `province_code`(后端 `@RequestParam(name = "province_code")`)。
+   * ★ 省码不在目录里 ⇒ 后端 **400**(不是空数组):空表在下拉里与「这个省真的没有市」
+   *   长得一样,所以失败要按失败处理,别静默吞掉。
+   */
+  regionCities(provinceCode: string) {
+    return request<ApiResponse<HarRegionVO[]>>({
+      url: `${API_PATH}/region/cities`,
+      method: "get",
+      params: { province_code: provinceCode },
+    });
+  },
+
   // ---------- 订单 ----------
 
   /** 订单列表(分页);silent = 后台静默刷新 */

+ 12 - 0
frontend/src/views/module_payment/apikey/docs/har-openapi.md

@@ -30,6 +30,8 @@
 
 **门店与下单的关系**:10.2 里的 `shop_code` / `third_shop_code` 都是**选填**。门店不是下单的前置条件 —— 只有需要把交易归属到某个具体门店时才填。
 
+> **但门店的「地区」是有用的**:平台的收款链接(第 6 章的顾客付款页)下单时会按顾客 IP 的归属地自动挑一家同地区的门店,见 10.1 的地区说明。你的接口自己下单时若指定了门店,那个地区的风控判断同样适用。
+
 ---
 
 ### 10.1 创建门店
@@ -47,6 +49,14 @@ POST /payment/openapi/har/shop/create
 | `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**:门店建在哪一个商户下,见本章开头。只配了一个的不用传 |
 | `third_shop_id` | string | 是 | 你方的门店编码,≤32 字符。**重复提交会被拒绝**,见下 |
 | `shop_name` | string | 是 | 门店名称,≤128 字符 |
+| `province_code` | string | **是**(2026-10-08 起) | 门店所在省,GB/T 2260 两位码,如 `32`(江苏省)、`11`(北京市) |
+| `city_code` | string | **是**(2026-10-08 起) | 门店所在市,GB/T 2260 四位码,如 `3201`(南京市)、`1101`(北京市)。**必须是 `province_code` 下的市**,编码填错一律 `code=400` 拒绝(`msg` 形如 `城市编码不属于该省: 1101(省 32)`;省码写错则为 `省份编码不存在: 99`)—— 是调用方的输入错误,改对再调即可 |
+
+> ⚠️ **`province_code` / `city_code` 自 2026-10-08 起为必填。** 在此之前提交成功的门店不传这两个字段也能建号,**现在不传会返回 `code=400`**(`msg` 为 `省份不能为空` / `城市不能为空`)。取值用国标 GB/T 2260 的行政区划代码:省级两位、地级四位,直辖市取 `1101`(北京市)/ `1201`(天津市)/ `3101`(上海市)/ `5001`(重庆市)、`5002`(重庆市县)。
+
+> ⚠️ **地区填错不会有任何报错,但这家门店会永远收不到款。** 支付宝对**异地收款**有风控,所以平台的**收款链接**(第 6 章的顾客付款页)在下单时会按**顾客 IP 的归属地**自动挑一家同地区的门店去发起收款。地区为空、或与顾客所在地不一致的门店,在挑店时直接落选。上游没有门店更新接口,**建号时填错就补不回来**,只能换一个新的 `third_shop_id` 重新建号。
+
+> **2026-10-08 之前建的门店**:那批门店没有地区,无法回填,**也不会被收款链接挑中**。让顾客扫码付款的门店必须重新建号。
 
 **响应**(B 型,`data` 内)
 
@@ -54,6 +64,7 @@ POST /payment/openapi/har/shop/create
 |---|---|
 | `third_shop_id` | 原样返回 |
 | `shop_name` | 原样返回 |
+| `province_code` / `city_code` | 原样返回(建号时提交的那两个码) |
 | `shop_id` | 平台门店编号,**建号成功才有值**;下单时作为 `shop_code` 传入 |
 | `create_status` | 建号结果,见下 |
 | `merchant_code` | 平台侧为你配置的商户编号(你无需关心,原样带回) |
@@ -310,6 +321,7 @@ Content-Type: application/x-www-form-urlencoded
 ## 11. 门店与订单通道的联调检查清单
 
 - [ ] 认证与签名复用 3.3 的实现,先跑通 3.4 的样例
+- [ ] 建号带了 `province_code` / `city_code`(GB/T 2260),且市码属于该省码
 - [ ] `price` 用**元**、写成最简小数(不要 `1.50`、`0.010`)
 - [ ] 下单**不重发** —— 超时先查 10.3,确认没有再决定
 - [ ] `auth_code` 有值走被扫、不传走主扫,两种都测过

+ 136 - 1
frontend/src/views/module_payment/har/shop/index.vue

@@ -96,6 +96,13 @@
         <el-table-column prop="shop_name" label="门店名称" min-width="150">
           <template #default="{ row }">{{ row.shop_name || "-" }}</template>
         </el-table-column>
+        <el-table-column label="地区" min-width="160">
+          <template #default="{ row }">
+            <span :class="{ 'region-missing': !row.province_code && !row.city_code }">
+              {{ regionLabel(row) }}
+            </span>
+          </template>
+        </el-table-column>
         <el-table-column prop="shop_id" label="平台门店编号" min-width="140">
           <template #default="{ row }">{{ row.shop_id || "-" }}</template>
         </el-table-column>
@@ -200,6 +207,36 @@
         <el-form-item label="门店名称" prop="shop_name">
           <el-input v-model="form.shop_name" maxlength="128" show-word-limit />
         </el-form-item>
+        <el-form-item label="省份" prop="province_code">
+          <!-- 两级都是必填(规范 261008 5.5)。取值只能来自这里给的下拉:
+               编码要进上游报文,也是收款时按顾客 IP 挑店的匹配键,手敲一个错码
+               就是一家永远挑不中的门店(上游没有更新接口,事后补不上) -->
+          <el-select
+            v-model="form.province_code"
+            placeholder="选择省份"
+            filterable
+            style="width: 100%"
+            @change="onProvinceChange"
+          >
+            <el-option v-for="p in provinceOptions" :key="p.code" :label="p.name" :value="p.code" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="城市" prop="city_code">
+          <el-select
+            v-model="form.city_code"
+            :disabled="!form.province_code"
+            :loading="cityLoading"
+            :placeholder="form.province_code ? '选择城市' : '请先选择省份'"
+            filterable
+            style="width: 100%"
+          >
+            <el-option v-for="c in currentCities" :key="c.code" :label="c.name" :value="c.code" />
+          </el-select>
+          <div class="form-tip">
+            支付宝对异地收款有风控:顾客付款时会自动用顾客所在地区的门店发起收款,
+            所以每家门店都要有准确的地区。
+          </div>
+        </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="createVisible = false">取消</el-button>
@@ -218,6 +255,7 @@
         </el-descriptions-item>
         <el-descriptions-item label="门店编码">{{ detail.third_shop_id }}</el-descriptions-item>
         <el-descriptions-item label="门店名称">{{ detail.shop_name || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="地区">{{ regionLabel(detail) }}</el-descriptions-item>
         <el-descriptions-item label="平台门店编号">
           {{ detail.shop_id || "未返回(创建未成功)" }}
         </el-descriptions-item>
@@ -247,13 +285,14 @@
 </template>
 
 <script setup lang="ts">
-import { onMounted, reactive, ref } from "vue";
+import { computed, onMounted, reactive, ref } from "vue";
 import { ElMessage, type FormInstance, type FormRules } from "element-plus";
 import dayjs from "dayjs";
 import HarAPI, {
   HAR_SHOP_STATUS_TAG,
   HAR_SHOP_STATUS_TEXT,
   type HarMerchantVO,
+  type HarRegionVO,
   type HarShopCreateParams,
   type HarShopVO,
 } from "@/api/module_payment/har";
@@ -264,6 +303,75 @@ const enterpriseStore = useEnterpriseStore();
 /** 本租户可见的商户配置 —— 建店时用哪一条由这里选 */
 const merchantOptions = ref<HarMerchantVO[]>([]);
 
+/**
+ * 省市目录 —— 建店表单的两级选择器,同时用来把列表里的**码**显示成**名**。
+ *
+ * ★ 目录来自后端(`/payment/har/region/*`),**页面不存第二份省市表**:编码是按顾客 IP
+ *   挑店时比对的键,两份表一旦分叉,运营选出来的门店就会「永远挑不中」。
+ * ★ 市按省缓存:列表每页可能有多个省的门店,缓存住免得每行各打一次。
+ *   失败的省**不入缓存**(留着下次再试),成功的即使是空数组也入缓存(那是上游的答案)。
+ */
+const provinceOptions = ref<HarRegionVO[]>([]);
+const cityCache = reactive<Record<string, HarRegionVO[]>>({});
+const cityLoading = ref(false);
+/** 正在拉的城市所属省码 —— 拉 A 省时切到 B 省,A 的响应回来不能当成 B 的 */
+let loadingCityOf = "";
+
+async function ensureProvinces() {
+  if (provinceOptions.value.length) {
+    return;
+  }
+  try {
+    const res = await HarAPI.regionProvinces();
+    provinceOptions.value = res.data.data || [];
+  } catch {
+    // 拉不到就保持空:选择器空着,用户看得见「没得选」,比给一份猜的表强
+    provinceOptions.value = [];
+  }
+}
+
+async function ensureCities(code?: string | null) {
+  if (!code || cityCache[code]) {
+    return;
+  }
+  loadingCityOf = code;
+  cityLoading.value = true;
+  try {
+    const res = await HarAPI.regionCities(code);
+    cityCache[code] = res.data.data || [];
+  } catch {
+    // 不入缓存 ⇒ 重新选一次省可以再试(省码不认识时后端是 400,重试也还是 400,
+    // 但那是给人看的错,不该被这里吞成一个空下拉)
+  } finally {
+    if (loadingCityOf === code) {
+      cityLoading.value = false;
+    }
+  }
+}
+
+function onProvinceChange(code: string) {
+  // 换省必须把市清掉:留着一个属于上一省的市码,提交出去就是「省 32 + 市 1101」,
+  // 后端按省查表会拒(正确),但用户看到的是自己明明选过城市
+  form.city_code = "";
+  ensureCities(code);
+}
+
+/**
+ * 行的地区显示名。码 → 名的对照取自目录;目录里没有就**如实回退到码本身**
+ * (不猜名字),两格都空则明说「未填」—— 2026-10-08 前建的店就是这一种,
+ * 它们在挑店时必然落选,页面若显示成有地区,运营会以为这家店还能收款。
+ */
+function regionLabel(row: HarShopVO) {
+  if (!row.province_code && !row.city_code) {
+    return "未填(不参与按地区收款)";
+  }
+  const province = provinceOptions.value.find((p) => p.code === row.province_code);
+  const city = (cityCache[row.province_code || ""] || []).find((c) => c.code === row.city_code);
+  return [province?.name || row.province_code, city?.name || row.city_code]
+    .filter(Boolean)
+    .join(" / ");
+}
+
 const list = ref<HarShopVO[]>([]);
 const total = ref(0);
 const pageNo = ref(1);
@@ -291,16 +399,27 @@ const defaultForm = (): HarShopCreateParams => ({
   enterprise_id: "",
   third_shop_id: "",
   shop_name: "",
+  province_code: "",
+  city_code: "",
 });
 
 const form = reactive<HarShopCreateParams>(defaultForm());
 
+/** 表单里当前可选的城市(市级下拉) */
+const currentCities = computed(() =>
+  form.province_code ? cityCache[form.province_code] || [] : []
+);
+
 const rules: FormRules = {
   // 企业**非必填**:留空即按租户建店(enterprise_id 落 NULL 的租户级映射),
   // 与 HarShopCreateDTO 上「选填」的注释、后端 HarShopService#create 的分流一致。
   // 商户同理非必填:本租户只有一条配置时留空即为那条;有多条时后端会 400 要求指定。
   third_shop_id: [{ required: true, message: "请填写门店编码", trigger: "blur" }],
   shop_name: [{ required: true, message: "请填写门店名称", trigger: "blur" }],
+  // 地区两个都必填(规范 261008 5.5):这既是发上游的必填项,也是按顾客 IP 挑店的键。
+  // 门店建完在上游**没有更新接口** ⇒ 这里漏填/填错就是一家永远收不到款的门店。
+  province_code: [{ required: true, message: "请选择省份", trigger: "change" }],
+  city_code: [{ required: true, message: "请选择城市", trigger: "change" }],
 };
 
 const merchantLabel = (m: HarMerchantVO) =>
@@ -335,6 +454,11 @@ async function load() {
     });
     list.value = res.data.data?.items || res.data.data?.list || [];
     total.value = res.data.data?.total || 0;
+    // 本页各行涉及的省各拉一次市(有缓存就不重复拉)——列表里要把码显示成名,
+    // 缺这一句时每行只能显示编码,运营得自己背 3201 是哪个市
+    ensureProvinces();
+    new Set(list.value.map((row) => row.province_code).filter((code): code is string => !!code))
+      .forEach((code) => ensureCities(code));
   } finally {
     loading.value = false;
   }
@@ -356,6 +480,8 @@ function handleReset() {
 function openCreate() {
   Object.assign(form, defaultForm());
   createVisible.value = true;
+  // 目录是异步来的(也只在真正建店时用得上),别放进 onMounted 让列表页白等
+  ensureProvinces();
 }
 
 function handleCreateClosed() {
@@ -389,6 +515,9 @@ async function openDetail(row: HarShopVO) {
   const res = await HarAPI.shopDetail(row.id as string);
   detail.value = res.data.data;
   detailVisible.value = true;
+  // 详情里也要把地区显示成名字(这家店可能不在本页列表里,缓存里未必有)
+  ensureProvinces();
+  ensureCities(detail.value?.province_code);
 }
 
 onMounted(async () => {
@@ -414,4 +543,10 @@ onMounted(async () => {
   line-height: 1.6;
   color: var(--el-text-color-secondary);
 }
+
+/* 缺地区的门店在收款链接里必然落选(2026-10-08 前建的店补不上)——
+   用警示色,别让它与「有地区」的普通文本长得一样 */
+.region-missing {
+  color: var(--el-color-danger);
+}
 </style>

+ 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;
 

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

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

+ 90 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarRegionController.java

@@ -0,0 +1,90 @@
+package com.payment.platform.module.payment.har.controller;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.har.dto.HarRegionVO;
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 省市目录(只读)—— 门店表单的级联选择器吃这两个端点。
+ *
+ * <h2>它为什么存在</h2>
+ * 门店自 2026-10-08 起必填省份/市(规范 5.5),而收款链接下单要按顾客 IP 挑同地区门店
+ * ⇒ **不给运营一个录地区的入口,整个功能就是死的**:没有门店带地区,每一笔收款链接下单
+ * 都会以「该地区没开通」拒收。
+ *
+ * <p>数据源是 {@link HarRegionCatalog}(打进包里的快照,同时也是顾客 IP 匹配用的那一份)
+ * ⇒ 页面选出来的编码与挑店比对的编码**同源**,不会各走各的。前端不要自己存一份省市表。
+ *
+ * <h2>三条实现上的规矩</h2>
+ * <ol>
+ *   <li><b>独立控制器</b>:不塞进 {@link HarShopController} —— 那边的 {@code /{id}}
+ *       会把 {@code regions} 当 {@code Long} 解析,得到的是一句类型转换 400;</li>
+ *   <li><b>不碰库、不涉租户</b>:目录是内存快照,一次库操作都没有,所以不需要租户上下文,
+ *       也不在 {@code TenantInnerInterceptor} 的管辖范围内;</li>
+ *   <li><b>省码不认识就 400,不回空表</b>:空表在下拉框里与「这个省真的没有市」
+ *       长得一模一样,而这里只有一种可能 —— 调用方拿了个我们目录里没有的码。
+ *       措辞与 {@code HarShopService#requireKnownCity} 同源。</li>
+ * </ol>
+ *
+ * <h2>权限串为什么是两条</h2>
+ * {@code shop:create}(在建店表单里用下拉的人)与 {@code shop:list}(能看门店页的人)都放行:
+ * 只认 {@code :create} 的话,「能看门店页但不建店」的账号打开页面会拿到 403。两个串在
+ * {@code db/har-menu.sql} 里本来就是一起下发的,这里多写一条只是把那个例外兜住。
+ * ★ 两个端点用**同一条**串:选择器的两跳要么都通、要么都不通。
+ */
+@Tag(name = "汇安融-省市目录")
+@RestController
+@RequestMapping("/payment/har/region")
+@RequiredArgsConstructor
+public class HarRegionController {
+
+    /**
+     * 两个端点共用的权限串。写成常量而不是两处字面量:编进注解是允许的
+     * (编译期常量),而「两跳权限不一致」是那种只在一半用户身上复现的故障。
+     */
+    private static final String PERM =
+            "@perm.hasAny('module_payment:har:shop:list','module_payment:har:shop:create')";
+
+    private final HarRegionCatalog regionCatalog;
+
+    /** 省级 31 个(港澳台不在内,见 {@link HarRegionCatalog} 的 javadoc) */
+    @GetMapping("/provinces")
+    @Operation(summary = "省级列表(门店表单级联选择器)")
+    @PreAuthorize(PERM)
+    public Result<List<HarRegionVO>> provinces() {
+        return Result.ok(regionCatalog.provinces().stream().map(HarRegionVO::of).toList());
+    }
+
+    /**
+     * 某省的地级列表。
+     *
+     * @param provinceCode 两位省码,必须是上面那个列表里给出的值
+     * @throws BusinessException 400 —— 空白或不在目录里(**不回空表**,理由见类 javadoc)
+     */
+    @GetMapping("/cities")
+    @Operation(summary = "某省的地级列表(门店表单级联选择器)")
+    @PreAuthorize(PERM)
+    public Result<List<HarRegionVO>> cities(@RequestParam(name = "province_code") String provinceCode) {
+        if (StrUtil.isBlank(provinceCode)) {
+            throw new BusinessException(400, "省份编码不能为空");
+        }
+        String code = provinceCode.trim();
+        boolean known = regionCatalog.provinces().stream().anyMatch(p -> code.equals(p.code()));
+        if (!known) {
+            throw new BusinessException(400, "省份编码不存在: " + provinceCode);
+        }
+        return Result.ok(regionCatalog.cities(code).stream().map(HarRegionVO::of).toList());
+    }
+}

+ 39 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarRegionVO.java

@@ -0,0 +1,39 @@
+package com.payment.platform.module.payment.har.dto;
+
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
+import lombok.Data;
+
+/**
+ * 一条地区记录 —— 省或地级,两种共用一个形状(都只有「码 + 名字」两格)。
+ *
+ * <p>前端门店表单的级联选择器吃它:{@code code} 是提交回 {@link HarShopCreateDTO} 的值,
+ * {@code name} 是给人看的。**没有第三格**(父级码之类):市列表本身就是按省查出来的,
+ * 再带一个省码回去是冗余,而冗余的那一格迟早会与请求参数不一致。
+ *
+ * <p>★ 只有这两格是因为:门店创建在上游是「一次成功、之后没有更新接口」的,
+ * 编码写错的门店永远挑不中 —— 所以这层能做的就是把目录的原文照搬给页面,
+ * 不加工、不缩写。
+ */
+@Data
+public class HarRegionVO {
+
+    /** GB/T 2260 编码:省两位({@code 32}),地级四位({@code 3201}) */
+    private String code;
+
+    /** 展示名(目录里那份,直辖市的地级是占位名换成的省名,见 HarRegionCatalog 的 javadoc) */
+    private String name;
+
+    public static HarRegionVO of(HarRegionCatalog.Province p) {
+        HarRegionVO vo = new HarRegionVO();
+        vo.setCode(p.code());
+        vo.setName(p.name());
+        return vo;
+    }
+
+    public static HarRegionVO of(HarRegionCatalog.City c) {
+        HarRegionVO vo = new HarRegionVO();
+        vo.setCode(c.code());
+        vo.setName(c.name());
+        return vo;
+    }
+}

+ 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;
+    }
+}

+ 132 - 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,31 @@ public class HarShopService {
         return HarShopVO.of(entity);
     }
 
+    /**
+     * 地区编码对表:省码要认识,市码还要**属于这个省**(市码本身全国唯一,但「这个码属于
+     * 这个省吗」必须连省一起看,否则省码写错也照样放行)。
+     *
+     * <p>省码与市码分开报错,是为了让运营照着字面就能改对 —— 「省市不匹配」这种笼统提示
+     * 会让人去翻数据字典。
+     *
+     * <p>两句都带**显式 400**:码是调用方填错的,不是我们这边出了故障。单参
+     * {@code BusinessException} 的默认码是 500(见 {@code BusinessException} 构造),
+     * 对外开放 API 的调用方会照着「服务器错误」来排查我们。同一份目录在只读端点
+     * ({@code HarRegionController})上也是 400,两处得是同一个码、同一句话。
+     *
+     * @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(400, "省份编码不存在: " + provinceCode);
+        }
+        return regionCatalog.cityByCode(pc, cityCode)
+                .orElseThrow(() -> new BusinessException(400,
+                        "城市编码不属于该省: " + 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);
+    }
+}

+ 56 - 0
java/src/main/resources/db/har-shop-region.sql

@@ -0,0 +1,56 @@
+-- pay_har_shop 新增 province_code / city_code(2026-10-08,规范 261008 5.5 + 异地收款风控)
+-- 手工执行: psycopg2 / psql 连接 payment_platform_java 库
+--
+-- 【本脚本尚未执行】执行由用户做。
+--
+-- ── 背景 ─────────────────────────────────────────────────────────────
+-- 《支付宝支付接口接入规范261008》门店创建(kf.har.shop.create)参数表新增了两行必填:
+--     province_code | 省份编码 | varchar(16) | √
+--     city_code     | 市编码   | varchar(16) | √
+-- 而支付宝对**异地收款**有风控 ⇒ 收款链接下单时要按顾客 IP 挑一家同地区的门店去
+-- precreate。挑店读的就是下面这两列(与 HarRegionCatalog 的 GB/T 2260 编码对上),
+-- 所以它们既是「发上游的必填」,也是「我们自己路由的键」。
+--
+-- 列宽 16 取自规范。两列**都不加 NOT NULL**:见下面那条「存量补不上」。
+--
+-- ── ⚠ 存量门店补不上,只能重开 ────────────────────────────────────────
+-- 上游**没有**门店查询/更新接口(见 V1.8 建表处注释与 HarShopEntity 的 javadoc)
+-- ⇒ 2026-10-08 之前建的门店,这两列永远是 NULL,无法回填。
+-- 它们在挑店时会被当成「地区不匹配」而**落选** —— 这是刻意的:宁可拒收,不可把
+-- 顾客导到异地门店(那正是这套东西要规避的风控)。
+-- 换句话说:**存量门店从本脚本执行起就不再能用于收款链接收款**,要收款得重新建店
+-- (换一个新的 third_shop_id)。
+--
+-- ── ⚠ 顺序:先执行本脚本,再部署代码 ──────────────────────────────────
+-- 反了的话不是「静默降级」而是**直接报错**:实体带上这两列后,门店列表与挑店
+-- 的每一句 SELECT 都会取这两列,列不存在 ⇒ 门店列表 500、且**每一笔收款链接下单
+-- 都失败**。顺序不能反。
+--
+-- ── 执行 ─────────────────────────────────────────────────────────────
+ALTER TABLE pay_har_shop ADD COLUMN IF NOT EXISTS province_code varchar(16);
+ALTER TABLE pay_har_shop ADD COLUMN IF NOT EXISTS city_code varchar(16);
+
+COMMENT ON COLUMN pay_har_shop.province_code IS
+    'GB/T 2260 两位省码(32=江苏省)。发上游 province_code(必填),也是按顾客 IP 挑店的匹配键。2026-10-08 之前建的门店为空,且无法回填';
+COMMENT ON COLUMN pay_har_shop.city_code IS
+    'GB/T 2260 四位市码(3201=南京市)。发上游 city_code(必填),见 province_code';
+
+-- 挑店那句查询按 merchant_code 过滤(租户谓词由拦截器另加)—— 它在收款链接的
+-- **每一笔支付**上执行,给个索引免得随门店铺开变成顺扫。
+CREATE INDEX IF NOT EXISTS idx_har_shop_merchant_code ON pay_har_shop (merchant_code);
+
+-- ── 只读核对(执行后跑)──────────────────────────────────────────────
+-- ① 列在不在、宽对不对
+-- SELECT column_name, data_type, character_maximum_length, is_nullable
+--   FROM information_schema.columns
+--  WHERE table_name = 'pay_har_shop' AND column_name IN ('province_code','city_code');
+-- 期望:两行,16,YES
+--
+-- ② 索引在不在
+-- SELECT indexname FROM pg_indexes
+--  WHERE tablename = 'pay_har_shop' AND indexname = 'idx_har_shop_merchant_code';
+-- 期望:一行
+--
+-- ③ ★ 有多少存量门店会因缺地区而落选(记下这个数字,它就是要重开的店数)
+-- SELECT count(*) AS 缺地区的店 FROM pay_har_shop
+--  WHERE province_code IS NULL OR city_code IS NULL;

+ 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": {
+  "亞": "亚",
+  "仝": "同",
+  "佈": "布",
+  "來": "来",
+  "倫": "伦",
+  "傢": "家",
+  "內": "内",
+  "則": "则",
+  "剋": "克",
+  "劃": "划",
+  "區": "区",
+  "吳": "吴",
+  "呂": "吕",
+  "哩": "里",
+  "囌": "苏",
+  "垻": "坝",
+  "壩": "坝",
+  "壯": "壮",
+  "婁": "娄",
+  "寧": "宁",
+  "寶": "宝",
+  "岡": "冈",
+  "峯": "峰",
+  "島": "岛",
+  "峽": "峡",
+  "崑": "昆",
+  "崗": "岗",
+  "嶺": "岭",
+  "嶽": "岳",
+  "巖": "岩",
+  "廈": "厦",
+  "廣": "广",
+  "張": "张",
+  "彥": "彦",
+  "彿": "佛",
+  "慶": "庆",
+  "懞": "蒙",
+  "懷": "怀",
+  "揚": "扬",
+  "撫": "抚",
+  "晉": "晋",
+  "東": "东",
+  "棗": "枣",
+  "樂": "乐",
+  "樑": "梁",
+  "樹": "树",
+  "檯": "台",
+  "欽": "钦",
+  "涼": "凉",
+  "溫": "温",
+  "滄": "沧",
+  "漢": "汉",
+  "澤": "泽",
+  "濛": "蒙",
+  "濟": "济",
+  "濰": "潍",
+  "濱": "滨",
+  "瀋": "沈",
+  "瀘": "泸",
+  "烏": "乌",
+  "無": "无",
+  "煙": "烟",
+  "營": "营",
+  "爾": "尔",
+  "瑪": "玛",
+  "甦": "苏",
+  "甯": "宁",
+  "畢": "毕",
+  "畫": "划",
+  "盤": "盘",
+  "矇": "蒙",
+  "節": "节",
+  "紅": "红",
+  "納": "纳",
+  "級": "级",
+  "紹": "绍",
+  "綏": "绥",
+  "維": "维",
+  "綿": "绵",
+  "縣": "县",
+  "義": "义",
+  "肅": "肃",
+  "臨": "临",
+  "臺": "台",
+  "興": "兴",
+  "荊": "荆",
+  "莊": "庄",
+  "華": "华",
+  "菸": "烟",
+  "蕪": "芜",
+  "薩": "萨",
+  "蘆": "芦",
+  "蘇": "苏",
+  "蘭": "兰",
+  "衕": "同",
+  "衛": "卫",
+  "裏": "里",
+  "裡": "里",
+  "許": "许",
+  "谿": "溪",
+  "貝": "贝",
+  "貢": "贡",
+  "貲": "资",
+  "貴": "贵",
+  "賀": "贺",
+  "資": "资",
+  "賓": "宾",
+  "贛": "赣",
+  "轄": "辖",
+  "迴": "回",
+  "連": "连",
+  "週": "周",
+  "運": "运",
+  "達": "达",
+  "遠": "远",
+  "遷": "迁",
+  "遼": "辽",
+  "邊": "边",
+  "鄉": "乡",
+  "鄭": "郑",
+  "鄲": "郸",
+  "銀": "银",
+  "銅": "铜",
+  "錦": "锦",
+  "錫": "锡",
+  "鎭": "镇",
+  "鎮": "镇",
+  "鐵": "铁",
+  "長": "长",
+  "門": "门",
+  "開": "开",
+  "閤": "合",
+  "關": "关",
+  "陝": "陕",
+  "陽": "阳",
+  "隨": "随",
+  "隴": "陇",
+  "雙": "双",
+  "雞": "鸡",
+  "雲": "云",
+  "頂": "顶",
+  "順": "顺",
+  "頗": "颇",
+  "頭": "头",
+  "颱": "台",
+  "颺": "扬",
+  "餘": "余",
+  "饒": "饶",
+  "馬": "马",
+  "駐": "驻",
+  "鬆": "松",
+  "魯": "鲁",
+  "鮮": "鲜",
+  "鴨": "鸭",
+  "鶴": "鹤",
+  "鷄": "鸡",
+  "鷹": "鹰",
+  "鹹": "咸",
+  "鹽": "盐",
+  "麗": "丽",
+  "麯": "曲",
+  "麴": "曲",
+  "黃": "黄",
+  "齊": "齐",
+  "龍": "龙",
+  "龢": "和"
+ }
+}

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

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

+ 177 - 0
java/src/test/java/com/payment/platform/module/payment/har/controller/HarRegionControllerTest.java

@@ -0,0 +1,177 @@
+package com.payment.platform.module.payment.har.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.har.dto.HarRegionVO;
+import com.payment.platform.module.payment.har.dto.HarShopCreateDTO;
+import com.payment.platform.module.payment.har.util.HarRegionCatalog;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Method;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 管理端「省市目录」两个只读端点 —— 门店表单的级联选择器吃它们。
+ *
+ * <h2>它为什么必须有(而不是「前端自己存一份省市表」)</h2>
+ * 门店自 2026-10-08 起**必填**省份/市(规范 5.5),而收款链接下单时要按顾客 IP 挑一家
+ * 同地区的门店 —— 也就是说,**不给运营一个能录地区的入口,这个功能就是死的**:
+ * 没有一家门店带地区 ⇒ 每一笔收款链接下单都会以「该地区没开通」拒收。
+ *
+ * <p>两份数据必须同源:前端自己存一份省市表,就会与后端那份(同时也是与顾客 IP 匹配的
+ * 那一份)**各走各的**,某天数据集更新后,运营在页面上选出来的编码会成为「永远挑不中」
+ * 的那种门店 —— 而上游没有门店更新接口,补不上。所以前端吃目录,目录只有一份。
+ *
+ * <h2>为什么单独一个控制器,不塞进 HarShopController</h2>
+ * 那边的 {@code @GetMapping("/{id}")} 会把 {@code /regions} 当成一个 {@code Long} 路径变量
+ * 去解析 ⇒ 结果是一句类型转换的 400,而不是这个目录。路径要独立。
+ *
+ * <h2>权限串为什么是两条</h2>
+ * 见 {@link #contract_pathAndPermissionAreTheOnesTheFrontendCalls()}。
+ *
+ * <h2>不碰库、不涉租户</h2>
+ * 目录是打进包里的内存快照({@link HarRegionCatalog}),两个端点**一次库操作都没有** ——
+ * 所以既不需要租户上下文,也不在 {@code TenantInnerInterceptor} 的管辖范围内。
+ */
+class HarRegionControllerTest {
+
+    /** 真目录:它就是这个端点的数据源,mock 掉等于把被测行为换成测试自己写的剧本 */
+    private final HarRegionCatalog catalog = new HarRegionCatalog();
+    private final HarRegionController controller = new HarRegionController(catalog);
+
+    /** 按码取名字;取不到就把**实际拿到的码**全打出来 —— 否则失败信息只有一句「没有 32」 */
+    private static String nameOf(List<HarRegionVO> rows, String code) {
+        return rows.stream()
+                .filter(v -> code.equals(v.getCode()))
+                .map(HarRegionVO::getName)
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("目录里没有 " + code
+                        + ";实际拿到的码=" + rows.stream().map(HarRegionVO::getCode).toList()));
+    }
+
+    // ─────────────────────────── ① 省 ───────────────────────────
+
+    @Test
+    void provinces_returnsTheWholeMainlandCatalog() {
+        Result<List<HarRegionVO>> r = controller.provinces();
+        List<HarRegionVO> rows = r.getData();
+
+        assertEquals(0, r.getCode(), "0 = 成功(Result.ok 的口径)");
+        assertEquals(31, rows.size(),
+                "省级 31 个。港澳台不在内是有意的:ipwho.is 对它们的 country_code 不是 CN,"
+                        + "解析那一步就先判掉了(见 HarIpRegionResolver 的 javadoc)");
+        // 抽三个点:首都、本功能最常用的那个省、名字最长的一个(自治区的归一化专挑它)
+        assertEquals("北京市", nameOf(rows, "11"));
+        assertEquals("江苏省", nameOf(rows, "32"));
+        assertEquals("新疆维吾尔自治区", nameOf(rows, "65"));
+        assertTrue(rows.stream().allMatch(v -> v.getCode() != null && v.getCode().length() == 2),
+                "两位省码是 GB/T 2260 的形状;选择器回填的就是这一格,缺一个字符都拼不上");
+        assertTrue(rows.stream().allMatch(v -> v.getName() != null && !v.getName().isBlank()),
+                "名字要直接上屏,空名字在下拉里是一个看不见的空行");
+    }
+
+    // ─────────────────────────── ② 市 ───────────────────────────
+
+    @Test
+    void cities_returnsOnlyThatProvincesCities() {
+        Result<List<HarRegionVO>> r = controller.cities("32");
+        List<HarRegionVO> rows = r.getData();
+
+        assertEquals(0, r.getCode());
+        assertEquals(13, rows.size(), "江苏 13 个地级");
+        assertEquals("南京市", nameOf(rows, "3201"));
+        assertTrue(rows.stream().allMatch(v -> v.getCode().length() == 4),
+                "四位市码");
+        assertTrue(rows.stream().allMatch(v -> v.getCode().startsWith("32")),
+                "返回的必须是**该省**的市:这是「按省取市」这件事本身");
+
+        // ★ 正对照:3401(合肥)在目录里确实存在 —— 少了这一句,上面那条否定断言
+        //   在「目录里根本没有 3401」时也会绿(那就是一条空断言)。
+        assertTrue(catalog.cities("34").stream().anyMatch(c -> "3401".equals(c.code())),
+                "正对照:3401 是安徽的合肥市,目录里有");
+        assertFalse(rows.stream().anyMatch(v -> "3401".equals(v.getCode())),
+                "安徽的市不许出现在江苏的列表里");
+
+        // 空白是查询串里最常见的手滑(手填 URL / 复制粘贴带空格)—— 认得出就别报 400
+        assertEquals(13, controller.cities(" 32 ").getData().size(),
+                "带空格的省码要先 trim 再查表,不能直接拿去比对");
+    }
+
+    // ─────────────────────── ③ 参数不对就 400 ───────────────────────
+
+    /**
+     * 省码不认识 ⇒ 400。**不返回空表**:空表在下拉里与「这个省真的没有市」长得一样,
+     * 而这里只有一种可能 —— 调用方传了个我们目录里没有的码(写错了、或前端存了一份旧表)。
+     */
+    @Test
+    void cities_unknownProvinceCode_400_notAnEmptyList() {
+        BusinessException ex = assertThrows(BusinessException.class, () -> controller.cities("99"));
+
+        assertEquals(400, ex.getCode());
+        assertEquals("省份编码不存在: 99", ex.getMessage(),
+                "措辞与 HarShopService#requireKnownCity 同源 —— 同一类错误在两处该是同一句话");
+    }
+
+    @Test
+    void cities_blankProvinceCode_400() {
+        assertEquals(400, assertThrows(BusinessException.class, () -> controller.cities(null)).getCode());
+        assertEquals(400, assertThrows(BusinessException.class, () -> controller.cities("   ")).getCode(),
+                "空白串不能当成一个「码」去查表(空格不是省码,查不到也不该静默回空表)");
+        assertEquals("省份编码不能为空",
+                assertThrows(BusinessException.class, () -> controller.cities(null)).getMessage());
+    }
+
+    // ─────────────────────── ④ 接口形状(契约) ───────────────────────
+
+    /**
+     * 前端要调的就是这两个形状,所以路径与方法级授权得有个钉住的地方。
+     *
+     * <p>★ 权限串**两条都写进 hasAny**:{@code shop:list}(能看门店页的人)与
+     * {@code shop:create}(在建店表单里要用下拉的人)。只写 {@code :create} 的话,
+     * 「能看门店页但不建店」的账号打开页面同样是 403 —— 而这两个串在菜单 SQL 里
+     * 本来就是一起下发的({@code db/har-menu.sql}),多写一条只是把那个例外兜住。
+     */
+    @Test
+    void contract_pathAndPermissionAreTheOnesTheFrontendCalls() throws Exception {
+        RequestMapping rm = HarRegionController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(rm, "类级 @RequestMapping 不得为空 —— 真实路径拼不出来");
+        assertEquals("/payment/har/region", rm.value()[0]);
+
+        Method provinces = HarRegionController.class.getMethod("provinces");
+        GetMapping gp = provinces.getAnnotation(GetMapping.class);
+        assertNotNull(gp, "省列表必须是 GET");
+        assertEquals(List.of("/provinces"), List.of(gp.value()));
+
+        Method cities = HarRegionController.class.getMethod("cities", String.class);
+        GetMapping gc = cities.getAnnotation(GetMapping.class);
+        assertNotNull(gc, "市列表必须是 GET");
+        assertEquals(List.of("/cities"), List.of(gc.value()));
+
+        // ★ 拼出来的是前端真正要调的那两条(分开断言会在「有人把两段一起改掉」时照样绿)
+        assertEquals("/payment/har/region/provinces", rm.value()[0] + gp.value()[0]);
+        assertEquals("/payment/har/region/cities", rm.value()[0] + gc.value()[0]);
+
+        PreAuthorize pa = provinces.getAnnotation(PreAuthorize.class);
+        PreAuthorize pc = cities.getAnnotation(PreAuthorize.class);
+        assertNotNull(pa, "端点必须挂权限串:不挂就是「登录即可」,任何账号都能读到内部编码表");
+        assertNotNull(pc);
+        // 逐字钉死(不是 contains):串写错是**静默的** —— 超管直通,拿超管跑的用例一条都测不出,
+        // 而普通账号会全线 403,那正好是「门店建不出来」的形态。
+        assertEquals("@perm.hasAny('module_payment:har:shop:list','module_payment:har:shop:create')",
+                pa.value());
+        assertEquals(pa.value(), pc.value(),
+                "两个端点同一条串 —— 选择器的两跳要么都通、要么都不通,半通的下拉框最难查");
+
+        // 其中一条必须与「创建门店」逐字相同(复用既有权限点,不新增菜单 SQL)
+        PreAuthorize shopCreate = HarShopController.class
+                .getMethod("create", HarShopCreateDTO.class)
+                .getAnnotation(PreAuthorize.class);
+        assertNotNull(shopCreate);
+        assertEquals("@perm.hasAny('module_payment:har:shop:create')", shopCreate.value());
+    }
+}

+ 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());
+    }
+}

+ 79 - 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,78 @@ 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());
+        // 码是自己填错的 ⇒ 调用方的输入错误,不是服务器故障。单参 BusinessException 默认 code=500,
+        // 对外开放 API 的调用方会照着「服务器错误」去排查我们这边。
+        // 同一份目录在只读端点上也是 400(见 HarRegionControllerTest),两处得是同一个码。
+        assertEquals(400, e.getCode(), "编码不认识该是 400,实际: " + e.getCode());
+        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());
+        assertEquals(400, e.getCode(), "编码不认识该是 400,实际: " + e.getCode());
+        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