18 コミット f09e9cce37 ... 41e6c63482

作者 SHA1 メッセージ 日付
  alphaH 41e6c63482 fix bug 1 週間 前
  alphaH ac2f4201cc docs(交付): 晚间补记 ② —— 文档收缩 + trade_type 兜底,现行 jar/dist 指纹换代 1 週間 前
  alphaH c07a10b688 feat(汇安融): trade_type 留空由服务端兜底 alipay_un_stage 1 週間 前
  alphaH eaaa742b17 docs(汇安融): 开放文档按裁定收缩 —— 删建店/被扫/挑店/分期等,trade_type 与 biz 退出调用方契约 1 週間 前
  alphaH 9020e88830 docs(交付): 🟡1 行为选项裁定 —— 维持现状(不改交互与业务逻辑) 1 週間 前
  alphaH 55080617e9 docs(交付): 两份交付说明短码示例订正为六位 + 10-09 批补记现行产物 1 週間 前
  alphaH 946a3639a9 fix(汇安融): 建店弹窗警示随编号自动生成同步 —— 删去已不成立的「重复提交会被后台拒绝」 1 週間 前
  alphaH 128a2276de feat(汇安融): 门店编号自动生成 —— SP + 时间戳 + 16 位 CSPRNG 随机段 1 週間 前
  alphaH 680406f9d7 fix(汇安融): 省市编码订正为 GB/T 2260 完整六位 —— 对齐汇安融对照表 1 週間 前
  alphaH 947454daa2 docs(交付): HAR 批交付说明 —— 挑店改版 + 去上游化 + 回调重试(3 提交 + jar/dist 指纹 + 必跑 SQL) 1 週間 前
  alphaH 6a0c72a6d7 feat(汇安融): 下游回调失败自动重试 —— 对齐乐收赢:首推一次 + 每分钟一轮,上限 8 次 1 週間 前
  alphaH d16f6f7539 feat(汇安融): 开放接口去上游化 —— 路径改 /payment/openapi/alipay、订单号前缀改 AP、biz 不再回吐 1 週間 前
  alphaH 94d120d132 feat(汇安融): 开放 API 挑店改版 —— 传顾客 IP 或省码(IP 优先、省码回退),不再收市码 1 週間 前
  alphaH d77b35bd0f docs(交付): 第六节领先计数改成自稳定措辞 —— 不写死总数,一律现量 1 週間 前
  alphaH 1e23d0a83f docs(交付): 门店地区交付说明追加第六节 —— 开放 API 下单直传地区码 1 週間 前
  alphaH fcf026c75d docs(汇安融): 开放文档 10.2 补 province_code / city_code —— 地区格式按接收口径逐字写清 1 週間 前
  alphaH cb153add53 feat(汇安融): 开放 API 下单支持直传地区码挑店 —— 不指定门店时由平台按 GB/T 2260 省市码挑一家 1 週間 前
  alphaH 2717f7937e test(汇安融): 湖南/长沙 的真实回包钉成夹具 —— 省带「省」市不带「市」 1 週間 前
55 ファイル変更、3012 行追加、1164 行削除
  1. 97 0
      .claude/plan/2026-10-08-har-shop-region-交付说明.md
  2. 177 0
      .claude/plan/2026-10-09-har-pick-dedup-retry-交付说明.md
  3. 5 5
      frontend/src/api/module_payment/har.ts
  4. 4 2
      frontend/src/plugins/permission.ts
  5. 6 4
      frontend/src/router/index.ts
  6. 28 0
      frontend/src/utils/shop-code.ts
  7. 5 5
      frontend/src/views/checkout/har/index.vue
  8. 4 4
      frontend/src/views/module_payment/apikey/docs/collection-openapi.md
  9. 58 144
      frontend/src/views/module_payment/apikey/docs/har-openapi.md
  10. 2 3
      frontend/src/views/module_payment/apikey/harDoc.ts
  11. 18 5
      frontend/src/views/module_payment/har/shop/index.vue
  12. 5 2
      java/src/main/java/com/payment/platform/core/security/SecurityConfig.java
  13. 10 7
      java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java
  14. 2 2
      java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarShopCreateRequest.java
  15. 8 0
      java/src/main/java/com/payment/platform/module/payment/har/config/HarProperties.java
  16. 1 1
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarOrderController.java
  17. 1 1
      java/src/main/java/com/payment/platform/module/payment/har/controller/HarRegionController.java
  18. 2 2
      java/src/main/java/com/payment/platform/module/payment/har/controller/open/HarOpenController.java
  19. 1 1
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutLinkVO.java
  20. 27 6
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderCreateDTO.java
  21. 5 3
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderVO.java
  22. 1 1
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarRegionVO.java
  23. 2 2
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopCreateDTO.java
  24. 2 2
      java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopVO.java
  25. 6 0
      java/src/main/java/com/payment/platform/module/payment/har/entity/HarOrderEntity.java
  26. 2 2
      java/src/main/java/com/payment/platform/module/payment/har/entity/HarShopEntity.java
  27. 1 1
      java/src/main/java/com/payment/platform/module/payment/har/enums/HarNotifyStatus.java
  28. 26 1
      java/src/main/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollScheduler.java
  29. 97 67
      java/src/main/java/com/payment/platform/module/payment/har/service/HarCallbackService.java
  30. 5 3
      java/src/main/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkService.java
  31. 130 16
      java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java
  32. 91 3
      java/src/main/java/com/payment/platform/module/payment/har/service/HarShopService.java
  33. 6 5
      java/src/main/java/com/payment/platform/module/payment/har/util/HarRegionCatalog.java
  34. 3 3
      java/src/main/resources/application.yml
  35. 66 0
      java/src/main/resources/db/har-callback-retry.sql
  36. 1 1
      java/src/main/resources/db/har-checkout-link.sql
  37. 1 1
      java/src/main/resources/db/har-order-refund-menu.sql
  38. 19 2
      java/src/main/resources/db/har-shop-region.sql
  39. 717 716
      java/src/main/resources/har-region-cn.json
  40. 2 2
      java/src/test/java/com/payment/platform/core/security/HarCheckoutWiringTest.java
  41. 96 0
      java/src/test/java/com/payment/platform/core/security/OpenapiAuthWiringTest.java
  42. 9 9
      java/src/test/java/com/payment/platform/module/payment/har/controller/HarCheckoutControllerTest.java
  43. 5 5
      java/src/test/java/com/payment/platform/module/payment/har/controller/HarMerchantControllerTest.java
  44. 16 16
      java/src/test/java/com/payment/platform/module/payment/har/controller/HarRegionControllerTest.java
  45. 2 2
      java/src/test/java/com/payment/platform/module/payment/har/controller/open/HarOpenControllerTest.java
  46. 25 1
      java/src/test/java/com/payment/platform/module/payment/har/dto/HarDtoJsonContractTest.java
  47. 89 0
      java/src/test/java/com/payment/platform/module/payment/har/dto/HarOrderCreateJsonContractTest.java
  48. 37 1
      java/src/test/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollSchedulerTest.java
  49. 324 0
      java/src/test/java/com/payment/platform/module/payment/har/service/HarCallbackServiceTest.java
  50. 7 7
      java/src/test/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkServiceTest.java
  51. 28 3
      java/src/test/java/com/payment/platform/module/payment/har/service/HarIpRegionResolverTest.java
  52. 432 2
      java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java
  53. 234 40
      java/src/test/java/com/payment/platform/module/payment/har/service/HarShopRegionPickTest.java
  54. 14 11
      java/src/test/java/com/payment/platform/module/payment/har/service/HarShopServiceTest.java
  55. 50 42
      java/src/test/java/com/payment/platform/module/payment/har/util/HarRegionCatalogTest.java

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

@@ -1,5 +1,8 @@
 # 汇安融「门店地区 + 按顾客 IP 挑店」交付说明(2026-10-08)
 
+> ★ **产物以《2026-10-09-har-pick-dedup-retry-交付说明.md》为准**(那篇含最新 jar/dist 指纹);
+> 本文件 §二 / §六 记的 jar/dist 均已作废,部署只按那一篇。
+
 需求原文(用户,指向《支付宝支付接口接入规范261008(1).docx》):
 「门店新增了地区相关的字段,需要接上,因为支付宝异地收款会有风控,我们的 checkout 也要做一个调整,
 在调起支付时要根据请求者 ip 自动选择对应地区的门店 id,这样才不会风控」
@@ -122,3 +125,97 @@
 `BusinessException` **不带 `@ResponseStatus`**(HTTP 200 + body `code`),顾客页自建实例
 读的是 `res.data.msg`;如果哪天有人把它改成真 4xx/5xx,顾客页会 fallback 成
 「网络异常,请检查网络后重试」,那句「你所在地区未开通」就再也到不了顾客眼前。
+
+---
+
+## 六、2026-10-09 追加:开放 API 下单直传地区码(★ 本节产物指纹取代 §二 的,旧件别再用)
+
+**需求(用户原文)**:「还有个点做了没?har的下单开放api,我看文档中没有让调用方传用户ip」
+→ 定案:「要么让调用方传用户地区也可以,省的我们解析了」→ 硬性要求:
+「那就传地区,但是要在开放文档里写清楚地区的格式,要按照我们接收的格式来,
+不然找不到门店就麻烦了」。
+
+**为什么不能按 IP**:openapi 是服务器对服务器调用,请求 IP 是**调用方机房**的,不是顾客的
+(只有收款链接那条腿能按 IP —— 顾客浏览器直连)。所以本次让调用方直传 **GB/T 2260 省市码**
+(不传 IP、平台不解析)。
+
+### 行为口径(实现前已与你确认,未改)
+
+1. 显式 `shop_code` / `third_shop_code` 优先,两个地区字段被忽略(现状不变);
+2. 都没给而给了任一地区码 ⇒ 平台按该地区挑一家,填进 `third_shop_code`
+   (**实体与上游请求两处**),响应回显挑中的那家;
+3. 市码选填:不给按全省挑;给了但**市内无可用店回退全省**(与收款链接腿同一步梯);
+4. **全省无可用店 ⇒ 400 拒收**,先于落库与上游(不留单、不打上游、不发订单信号)——
+   相对「静默退回不带门店下单」,后者等于调用方明确要求的地区归属悄悄落空;
+5. 只给 `city_code` 不给 `province_code` ⇒ 400(省码是地区定位的根);
+6. 同地区多店的稳定散列种子 = **`out_biz_no`**(同一 `out_biz_no` 重复调用挑到同一家)。
+
+### 提交(本节写成的当刻:`origin/java` 停在 `f09e9cce`;未推 = `2717f793`(夹具,本批之前)+ 下表两刀 + 本节自身的提交及其订正;**不写死总数** —— 一律以 `git rev-list --count origin/java..java` 现量为准)
+
+| 提交 | 内容 |
+|---|---|
+| `cb153add` | 后端:DTO 两个选填字段;`HarShopService#pickForOrder`(校验 + 挑店 + 三种 400,抽出 `requireKnownProvince` 与建店共用同一句文案);`HarOrderService#create` 接线(挑店块 + builder 带 `thirdShopCode`);新契约测试类 `HarOrderCreateJsonContractTest` |
+| `fcf026c7` | 开放文档 `har-openapi.md` 10.2:字段两行 + 挑店说明块(**格式按接收口径逐字**)+ 响应表 `third_shop_code` 语义订正 + 章首补句 |
+
+### 验证读数(本机实测)
+
+- **RED**(先写测试,`logs/_tdd-region-red-1.txt`):三个测试文件红于编译错 ——
+  `pickForOrder` 不存在(8 处)、`setProvinceCode`/`setCityCode` 不存在、
+  `HarOrderService` 期望 4 参实到 5 参。原因全部是「功能不存在」,无笔误。
+- **GREEN 聚焦**(`logs/_tdd-region-green-1.txt`):**86/0/0/0** =
+  挑店 22(13→22)+ 下单 61(54→61)+ 新契约 3。
+- **变异两向**(5 个真实变异 + 1 个无害改动,全部先确认变异体生效、还原后逐字节回基线):
+
+  | 变异体 | 红集(预测=实测) |
+  |---|---|
+  | 城市码整个忽略 | 4 红(cityTier 挑成 SZ + 三条 400 文案/校验),82 对照绿 |
+  | 显式门店码优先失效 | 2 红(两个 explicit 用例 NPE),84 对照绿 |
+  | 挑不出来不拒收 | 3 红(三条 400 守卫,none thrown),83 对照绿 |
+  | 散列种子换成 enterpriseId | 4 红(四条钉了精确种子的 create 用例),82 对照绿 |
+  | 上游发 dto 的而不是挑中的 | 1 红(「挑中的店必须真的发到上游」NJ→null),85 对照绿 |
+  | 无害重构(守卫提局部变量) | 全绿 86/86 ✓ |
+
+  还原复核:`HarShopService` `6341E642…`、`HarOrderService` `41745D49…`、
+  `HarOrderCreateDTO` `40165A56…`(与变异前一致)。
+- **整包**:**1232 条,0 失败 0 错误 0 跳过,BUILD SUCCESS**,32.298s
+  (`logs/_mvn-full-region-order.txt`;1232 = 上一批 1213 + 本批 19)。
+- **产物**(★ 取代 §二 的指纹;旧 jar `b6b64c8a…` / 旧 dist `80038a66…` 作废):
+  - `java/target/payment-platform-1.0.0.jar`,143,147,907 字节,12:13:28,
+    sha256 `2F51689B49F8B51D5B890973BEDDF30557A553AD04CA732DA5D5BC2B772133D5`。
+    开包回读(`logs/_jar-readback-region-order.py`,只读 jar 里的 .class 常量池):
+    `pickForOrder`、`requireKnownProvince`、四条 400 文案前缀、
+    DTO 的 `provinceCode/cityCode/getProvinceCode/setCityCode` 全为 True。
+  - `frontend/dist.zip`,9,376,009 字节,12:15:03,
+    sha256 `3cbbdfffae3d6638fc662af1985173b685aafa928caa10c3ea2c4796d0c247b6`。
+    281 条目 = 277 文件 + 4 目录条目(与上一版**同形**)、**全正斜杠**、`testzip()` 无坏成员;
+    新文档句(「按地区自动挑店」「就必须一并传 province_code」「该地区没有可用门店」)
+    **只**出现在 `dist/js/index.BRvi1q4X.js` —— 构建产物回读,zip 内也回读到同一文件
+    (`logs/_dist-zip-region-order.py`)。
+- **ts:check 没重跑**:本批只动 md(文档经 `?raw` 导入、类型恒为 string),未新增前端代码 ——
+  与上批「md 改动不进类型检查」的同一条实测口径。
+
+### 开放文档写了什么(你点名要的「格式」)
+
+10.2 请求表加 `province_code`/`city_code` 两行,其后一块说明写死:**与 10.1 建店 GB/T 2260
+同一套码**(完整六位:省级 XX0000、地级 XXXX00);**传代码不是地名**;`city_code` 必须属于 `province_code`;
+**只在没传任何门店码时生效**;先同市、市无店回退全省;同地区多店按 `out_biz_no` 稳定;
+挑中回显在响应 `third_shop_code`;三种 400 文案逐字(`省份编码不存在: 999999` /
+`城市编码不属于该省: 330100(省 320000)` / `省份编码不能为空: 传了 city_code 就必须一并传 province_code` /
+`该地区没有可用门店: 省 320000`(带市码时 `该地区没有可用门店: 320100(省 320000)`))——
+**这四个字符串与测试里钉的完全同一批**,文档不是手抄的。(示例码原为短码,2026-10-09 按 GB/T 2260 六位订正,见 `680406f9`。)
+
+### 部署(与 §三 同序,换新 jar/dist 即可)
+
+1. 先跑 `db/har-shop-region.sql`(若尚未跑;跑过就不必重复);
+2. 换 `2F51689B…` 的 jar + `3cbbdfff…` 的 dist.zip;
+3. 无需新菜单 SQL、无需重登。
+
+### 悬置/未验(如实)
+
+1. **收银台腿(`HarCashierService#createQr`)不带门店码,本批未动** —— 它要不要一并接
+   「地区」或「挑店」,待你拍板(现状=不带,不是回归)。
+2. **「全省无店 ⇒ 400 拒收」按已批准设计执行**(未再单独确认过,若你要改成「允许不带门店
+   直接下单」请说)。
+3. 挑店在**真实调用方**下的表现未端到端跑过(开放 API 需要一条真签名请求);本次只到
+   「单元 + 契约 + 产物回读」这一层。
+4. `pay_url` 真机扫码仍未做过(老账,与 §四.2 相同)。

+ 177 - 0
.claude/plan/2026-10-09-har-pick-dedup-retry-交付说明.md

@@ -0,0 +1,177 @@
+# 2026-10-09 交付说明 —— 开放 API 挑店改版 + 去上游化 + 下游回调重试(汇安融 HAR)
+
+> 对象:`java` 分支 3 个提交 + 1 个 jar + 1 个 dist.zip + 1 个必跑 SQL。
+> 状态:**已提交、未 push**(push 由你做)。origin/java = `f09e9cce`;
+> 写本文件时 `git rev-list --count origin/java..java` = **8**(上批 region 5 个 + 本批 3 个;本文件自身的提交不计入)。
+
+## ★ 2026-10-09 晚间补记 —— 批后又追加 3 个提交(六位订正 / 编号生成 / 评审修复)
+
+正文写于当天下午(批 = 3 提交 + jar `8662237e…` + dist `4b87bb40…`);此后 `java` 分支又追加:
+
+- `680406f9` 省市编码订正为 GB/T 2260 **完整六位**(20 文件:`har-region-cn.json` 重生成、7 个测试类改钉、8 处 javadoc、前端 `har.ts`/`har-openapi.md`/建店表单;`db/har-shop-region.sql` 追加「短码订正」幂等 UPDATE 段)
+- `128a2276` 建店门店编号自动生成(前端 2 文件:`utils/shop-code.ts` + 建店表单;`SP` + 时间戳 + 16 位 CSPRNG,只读、无「重新生成」按钮)
+- `946a3639` 建店弹窗警示随编号自动生成同步 —— 删去已不成立的「重复提交会被后台拒绝」,改为如实陈述(独立评审 🟡1)
+
+**现行产物(换这一套,覆盖 §二里记的指纹)**:
+- jar:`5e5bfb4747be…`(143,153,224 字节,17:08:09;含六位订正)
+- dist.zip:`ab7c0c96fbb8…`(9,377,220 字节,17:23:49;含编号生成 + 弹窗警示修复)
+
+> ↑ 这两件已被下面「晚间补记 ②」的新产物覆盖 —— 交付时以那一套为准。
+
+部署顺序不变;两个 SQL **都要跑**:`db/har-callback-retry.sql` **且** `db/har-shop-region.sql`(后者自 2026-10-09 起含短码订正段);无重登、无新菜单。本补记同时把两处残留短码示例订正为六位(评审 🟡2)。写本补记时 `git rev-list --count origin/java..java` = 12,本提交计入则 13。
+
+## ★ 2026-10-09 晚间补记 ② —— 开放文档再收缩(trade_type / biz 隐藏)+ 后端兜底
+
+两个提交(写本节时 `git rev-list --count origin/java..java` = 16,本节提交计入则 17):
+
+- `eaaa742b` 开放文档按裁定收缩(`har-openapi.md` 52+/152−、`harDoc.ts` 注释同步):删建店整节、
+  `auth_code` / `province_code` / `extra_params` / `stage_num` / `percent` / `trade_type` / `biz` 七行、
+  主扫/被扫两场景表、按地区挑店说明块、与第 5 章的差异说明;响应表与回调示例同步收窄;
+  小节号原 10.2~10.6 → 10.1~10.5(30 处引用两向核对)
+- `c07a10b6` `trade_type` 留空由服务端兜底 `alipay_un_stage`:DTO 去 `@NotBlank`,
+  `HarOrderService.resolveTradeType` 供落库 / precreate / pay / `resolveBiz` 派生读同一份;
+  两条新测试先红后绿(null 走 precreate 腿、空串走 pay 腿)
+
+**现行产物(换这一套,覆盖上面两条补记里的指纹)**:
+
+- jar:sha256 `859a6c54cea599e4f40673be32ec3f2f81a6ead2850ef6dfb0c941ab609ff666`(143,153,263 字节,18:06:19)
+- dist.zip:sha256 `41c1b1ac749c9708d48a86a7fc52b30a52d7c436ee60bdc16ca0b7ebd710d5b8`(9,373,646 字节,18:08:11;281 条目 = 277 文件 + 4 目录项,与上一套同形)
+
+判据(各带正对照):jar 内 `HarOrderCreateDTO.class` 常量池已无「交易方式不能为空」、「商品名称不能为空」仍在;
+`HarOrderService.class` 里 `resolveTradeType` 在。dist 内文档 chunk(`index.CWrhnCFM.js`)锚点「本章接口路径前缀」在,
+`trade_type` / `alipay_un_stage` / 「留空即可」均 0 命中,`| \`title\` | 原样返回 |` 与 `out_biz_no` 命中为正对照。
+后端的 `mvn test` 全绿读数:**1268 / 0 失败 / 0 错误**(控制台汇总与 100 份 surefire XML 求和两读一致)。
+
+★ **文档从简、报文没简**:开放查询响应仍回吐 `trade_type`(VO 里连同 `stage_num` / `percent`),
+回调 `content` 仍带 `trade_type` —— 按「隐藏」裁定文档不再列它们,但字段仍在报文里;
+把它们从报文里去掉属于改契约,未做。
+
+部署步骤不变:先跑 `db/har-callback-retry.sql` + `db/har-shop-region.sql`,再 jar + dist 同批换;
+无新 SQL、无重登、无新菜单。
+
+## 需求原文与裁定
+
+### ① 开放 API 挑店改版(C)
+
+原文:「开放api不用传市了,传省份或者ip,ip精确度更高所以优先级高于省份字段,两个可以同时传也可以只传其中一个,只传省份且有多个市级门店的情况下随机使用其中一个门店」
+
+裁定与落地:
+
+- IP 解析失败 / 该 IP 解析出归属地但无店 ⇒ **回退省份码继续挑**(两者都没有才报错)
+- 稳定散列(同一 `out_biz_no` 恒挑同一家);`out_biz_no` 为空 ⇒ 随机
+- `city_code` 字段**删除**;误传须响亮失败(Jackson `UnrecognizedPropertyException`,不静默忽略)
+- 400 文案三句:`省份编码不存在: 999999` / `用户 IP 无法解析出归属地: 1.2.3.4` / `该地区没有可用门店: 省 320000`(带市时 `该地区没有可用门店: 430100(省 430000)`)
+- 显式门店码(`third_shop_id`)优先——传了就不挑
+
+### ② 开放接口去上游化(B)
+
+原文:「开放接口中暴露了上游har,把名字换了,不要暴露给调用方」
+
+裁定与落地:
+
+- 范围:开放 API 路径 + 文档 + 订单号前缀 + 顾客页 URL 里露出的字样
+- 新路径词 `alipay`:`/payment/openapi/har/**` → `/payment/openapi/alipay/**`。
+  双白名单**同名**改(`SecurityConfig` 的 permitAll + `TenantApiKeyAuthFilter` 的前缀/放行清单)——只改一处就是「被放行 + 无人认证」的零鉴权(2026-09-24 踩过)
+- 订单号前缀 `HAR` → `AP`(仅新单;存量单号不动)
+- 顾客页 URL `/#/checkout/har/<token>` → `/#/checkout/alipay/<token>`(路由名 `HarCheckout` 不变;后端 `PATH_PREFIX` 与前端路由段必须同名改,单边改 = 整页 404)
+- `biz` 不再回吐给调用方;留空时服务端按 `trade_type` 派生(`alipay_un_stage` ⇒ `har_ali_un_stage`)
+- **后台接口路径未动**(`HarOrderController` 只改了一句 javadoc;`/payment/har/checkout/...` 等后台端点原样)
+
+### ③ 下游回调失败自动重试(A)
+
+原文与裁定:「1、维持租户级兜底,一个租户本来只有一个回调地址 2、存量不补推 做」;另:「直接用我们后台页面生成的checkout码进行支付,会给商家回调吗?如果没有的话也要做」
+
+裁定与落地:
+
+- 对齐乐收赢:首推一次 + 调度器每 60s 捞一轮(FAILED 且未达上限、饿最久优先、单轮 50 条),上限 8 次(含首推),`har.callback-max-retries` 可配
+- 缺回调地址也落 FAILED(不再停 PENDING);每次尝试写 `notify_count` / `last_notify_time`
+- 轮询腿真流转(rows>0 且状态/退款时间真变)后补发回调 —— 上游通知窗口只有约 25 秒,丢了的靠这条收敛;退款抢占/回退两条腿也各补一帧;create 不推
+- **存量 FAILED 不补推**(SQL 封顶 `notify_count=8`)
+
+## 一、提交清单
+
+| # | commit | 内容 | 规模 |
+|---|---|---|---|
+| C | `94d120d1` | feat(汇安融): 开放 API 挑店改版 —— 传顾客 IP 或省码(IP 优先、省码回退),不再收市码 | 8 文件 +263/−109 |
+| B | `d16f6f75` | feat(汇安融): 开放接口去上游化 —— 路径改 /payment/openapi/alipay、订单号前缀改 AP、biz 不再回吐 | 24 文件 +312/−70 |
+| A | `6a0c72a6` | feat(汇安融): 下游回调失败自动重试 —— 对齐乐收赢:首推一次 + 每分钟一轮,上限 8 次 | 11 文件 +774/−77 |
+
+全批 36 个文件(33 改 + 3 新:`OpenapiAuthWiringTest.java`、`HarCallbackServiceTest.java`、`db/har-callback-retry.sql`)。
+
+**拆分方法(时序重放)**:三件事改在同一批文件上(`HarOrderService`、`har-openapi.md`、多个测试类被三件事同时动过),逐文件 `git add` 拆不干净。做法:把后做任务的 hunk 逐条 Edit 反向抹掉 → 只留 C 的树 → 提交 → 从 `logs/_split-2026-10-09/` 的基线**字节级还原**(逐文件 `cmp` 核过)→ 再抹掉 A → 提交 B → 还原全部 → 提交 A。每阶段做了残留令牌扫描(只看 changed line,排除 hunk 头回声)确认没有串味;`git commit` 带显式 pathspec,两个预暂存的 `D logs/*.log.gz` 没有被卷进去。拆分中间树不是全绿的,只跑各阶段相关测试类;全量只在最终树跑。
+
+## 二、验证读数
+
+### 阶段测试(各阶段只跑相关类)
+
+- C:**111 条 / 0 失败**(HarShopRegionPickTest 28、HarOrderServiceTest 61、HarShopServiceTest 18、HarOrderCreateJsonContractTest 4)
+- B:**112 条 / 0 失败**(7 类,含新 `OpenapiAuthWiringTest` 2 条 —— 关掉「白名单改了、filter 没认」的零鉴权盲区)
+- A:**90 条 / 0 失败**(HarCallbackServiceTest 8、HarOrderPollSchedulerTest 8、HarOrderServiceTest 74)
+
+日志:`logs/_split-2026-10-09/stage-{c,b,a}-test.log`
+
+### 全量
+
+**1266 条 / 0 失败 / 0 错误 / 0 跳过 / BUILD SUCCESS**(上批 region 1232 ⇒ +34)。
+日志:`logs/_split-2026-10-09/final-full-suite.log`
+
+### jar
+
+- `java/target/payment-platform-1.0.0.jar`:**143,152,531 字节**,mtime `2026-10-09 13:37:33`,
+  sha256 `8662237ebc127bb5b28b43b96d54c578036e85c96d21861e4169ddc127ee29f6`
+  (**覆盖上批 region 的 `2F51689B…`** —— 换 jar 认这一份)
+- 回读取证(`logs/_split-2026-10-09/jar-readback-batch.py`,17/17 OK,直接从 jar 里读 class 常量池):
+  - C:`pickForOrder` / `ipRegionResolver` / `用户 IP 无法解析出归属地` / `用户 IP 与省份编码至少填一个` / `该地区没有可用门店` / `省份编码不存在` 均在场;DTO `provinceCode`、`getIp` 在场;**`cityCode` 不在**(已删)
+  - B:`resolveBiz` / `har_ali_un_stage` 在场;裸 `HAR` 常量**不在**(前缀已改 `AP`)
+  - A:`notifyCount` / `lastNotifyTime` / `callbackMaxRetries` / `notifyEnterprise` / `transitioned` 在场
+
+### dist.zip
+
+- `frontend/dist.zip`:**9,376,648 字节**,mtime `2026-10-09 13:38:56`,
+  sha256 `4b87bb40d71df00401df5c78df246cf5b77b84d756d4bee6c7ccad76f491dcdf`
+- 281 条目 = 277 文件 + 4 目录;**0 条反斜杠条目**;`testzip` 干净(`dist-zip-verify-batch.py` 打包并回读)
+- 产物串扫:`/payment/openapi/alipay`、`/checkout/alipay/`、`用户 IP 无法解析出归属地`、`随机挑一家`、`首推只发一次`、`AP1234567890` 各在场(各命中 1 个 chunk);`openapi/har`、`没配就不会推送`、`HAR1234567890` 零命中
+- **一个探针假阳性,如实记录**:`/checkout/har/` 在 1 个 chunk(`index.DziFqCFC.js`)命中 —— 那是 Vite 动态导入表里页面组件的**目录名**(`views/checkout/har/index.vue`,页面目录未改名),**不是路由路径**;路由已是 `/checkout/alipay/:token`(源码里 `checkout/har` 只作为 import 路径存在)。探针期望写错,不是代码错
+
+### 前端类型检查(同机差分)
+
+`pnpm ts:check` exit 2、**289 条错误** —— 与基线(上批 `logs/_tscheck-region-code-400.txt`)**逐文件分布 diff 为空**:零新增、零消失(该基线本身就是非零,只能差分用)。
+
+## 三、部署要求(按顺序)
+
+1. **先跑 SQL,再换 jar**:
+   - **必跑**:`java/src/main/resources/db/har-callback-retry.sql` —— 加 `notify_count` / `last_notify_time` 两列 + 部分索引 `idx_har_order_callback_retry` + 存量 FAILED 封顶 `notify_count=8`(「存量不补推」)。**列不存在时回调状态会永远停在 PENDING**,捞取腿每分钟报错
+   - 若以下前置批次的 SQL 还没跑过,按各自批次说明补跑:`db/har-shop-region.sql`(地区批)、`db/har-checkout-link.sql`(收款链接批)
+   - 本批动过的两个 SQL(`db/har-order-refund-menu.sql`、`db/har-checkout-link.sql`)**只有注释文字改动**(diff 逐行核过),不必因本批重跑
+2. **换 jar + 换 dist.zip,必须同批**:顾客页路径前缀 `har`→`alipay` 后端(`PATH_PREFIX`,jar 里)与前端路由(dist 里)各执一份,单边换 ⇒ 新链接整页 404;开放接口新前缀在 jar 里、开放文档页在 dist 里,同理。
+   jar 指纹 `8662237e…`(143,152,531 字节)、dist.zip 指纹 `4b87bb40…`(9,376,648 字节),见上。
+3. **无需重登、无需新菜单**:后台接口路径未动,本批无新权限点、无新菜单。
+4. 验收路径:
+   - 开放 API:用新前缀 `/payment/openapi/alipay/**` 走一单;旧前缀 404/不放行**是有意的**
+   - 挑店:传 `ip` 或 `province_code`(`city_code` 误传应响亮 400);同一 `out_biz_no` 恒挑同一家
+   - 回调:把某租户回调地址指到非 2xx / 不可达地址,观察 `notify_count` 每分钟 +1、`last_notify_time` 更新、8 次封顶
+   - 收款链接:后台生成链接,URL 应为 `/#/checkout/alipay/<token>`,打开能进顾客页
+
+## 四、未验项(如实列)
+
+1. **前端无执行级验证**(项目无前端测试框架):本批前端改动 = 一个路由段(+注释)、两个 md 文档;证据 = 构建通过 + 产物回读 + ts:check 零新增。**「顾客页真打开能付」「后台页面能开」没跑过**(需登录态 / 真环境)。
+2. **挑店没在真实调用方签名请求下端到端跑过**;IP 解析(外呼)只到单测层(mock)。
+3. **回调重试的真实失败→重试循环没在生产观察过**(证据 = 单测 + SQL 封顶;上线后第一次真实失败时留意日志与表)。
+4. **`pay_url` 真机扫码仍未做过**(老账,承接批)。
+5. **老 checkout 链接**:URL 是读时拼的(`buildUrl(token, request)`,库里不存 URL),token 不变;但已外发过的 `/#/checkout/har/` 形链接换版后**会打不开**(老路由段没了)。该功能此前未部署过(其 SQL 是部署前置),线上应无此类链接在流通 —— 以你的部署记录为准。
+6. `third_shop_id` 重复仍回 500(老账,未动,地区批已记)。
+
+## 五、拍板 / 挂账(承接)
+
+- 收银台腿不带门店码(地区批挂账,待拍板)
+- 收款链接批:F1 限流 / F5 订单号前缀(待拍板)
+- 挑店稳定散列 seed 用 `out_biz_no`(已实现);调用方不传 `out_biz_no` 时等价于随机 —— 开放文档已写明
+
+### 2026-10-09 独立评审后新增挂账(0 🔴 / 2 🟡:🟡1 已裁定维持现状、🟡2 已随本批修复;5 💭 挂账)
+
+- 🟡1 行为层后半「提交成功前保留同一编号」—— **已裁定:维持现状,不做行为修法**。依据(用户 2026-10-09 委托决策,约束原文「不能影响交互逻辑和业务逻辑」):三类候选修法均越线 —— 粘住编号 = 改弹窗打开后的展示(交互),加「重新生成」按钮 = 新增控件(交互),后端拒绝重复建店 = 新增拒绝规则(业务)。文案层修复(`946a3639`)已消除缺陷实质:旧承诺「重复提交会被后台拒绝」是假话,现已如实。当前生产 kf 建店路由尚未开通(2026-10-09 实测 404,上游侧待开)⇒ 上游无法建出隐藏门店,生产无敞口;路由开通后残余风险 = 运营在「待核实」后主动重开重提,弹窗文案已明示「请勿」。若日后仍要该行为,须作为独立需求(含换店逃逸口)走流程
+- 短码订正 SQL 段 guard 偏宽:`length(x) < 6` 会把 `''`/任意短垃圾(如 `99`)补成看似合法的六位;建议 `BETWEEN 2 AND 5`(异常留在 `< 6`,只读核对④才拦得住),未动
+- `HarRegionControllerTest:90` 松懈断言 `startsWith("32")`(88 行已另钉 `length == 6`,组合偏松);建议 `startsWith("3200")` 或 equals,未动
+- `utils/shop-code.ts` 注释动机(「能在电话/微信里念得出来」)与只读自动生成格不符;建议改注释真实动机,未动
+- `logs/` 未被 gitignore:`git add -A` 会连截图/生成器/台子/日志一起收走(本仓提交一直走显式路径);建议加 ignore 条目,未动
+- 省直辖县级行政区划 4 码(419000/429000/469000/659000)截图不可见、按补零规则推;建店选到这些省前先找上游确认

+ 5 - 5
frontend/src/api/module_payment/har.ts

@@ -65,7 +65,7 @@ export interface HarCheckoutLinkVO {
   merchant_code: string;
   /** 商户显示名(**快照**)。可能为 null:商户行没填名字时不编一个出来 */
   merchant_name?: string | null;
-  /** 完整收款链接(形如 `https://<域名>/#/checkout/har/<token>`) */
+  /** 完整收款链接(形如 `https://<域名>/#/checkout/alipay/<token>`) */
   url: string;
 }
 
@@ -78,14 +78,14 @@ export interface HarShopVO {
   third_shop_id: string;
   shop_name?: string;
   /**
-   * GB/T 2260 两位省码(`32`=江苏省)。
+   * GB/T 2260 六位省码(`320000`=江苏省)。
    *
    * ★ 可空**只对 2026-10-08 之前建的门店**成立:上游没有门店更新接口,那批店补不上,
    *   挑店时会被当成「地区不匹配」落选 ⇒ 页面必须把「未填」显示成未填,
    *   不能看着像有地区(见 shop/index.vue 的 regionLabel)。
    */
   province_code?: string | null;
-  /** GB/T 2260 四位市码(`3201`=南京市),见 province_code */
+  /** GB/T 2260 六位市码(`320100`=南京市),见 province_code */
   city_code?: string | null;
   /** 上游返回的平台门店编号,创建成功才有值 */
   shop_id?: string;
@@ -128,9 +128,9 @@ export interface HarShopCreateParams {
  *   不要在前端另存省市表,也不要对码做加工(补零、截断、拼接)。
  */
 export interface HarRegionVO {
-  /** GB/T 2260:省两位(`32`),地级四位(`3201`) */
+  /** GB/T 2260 完整六位:省 `320000`,地级 `320100` */
   code: string;
-  /** 展示名。直辖市的地级是占位名换成的省名(重庆给出 5001/5002 两个) */
+  /** 展示名。直辖市的地级是占位名换成的省名(重庆给出 500100/500200 两个) */
   name: string;
 }
 

+ 4 - 2
frontend/src/plugins/permission.ts

@@ -16,9 +16,11 @@ import { usePermissionStore, useUserStore, useEnterpriseStore } from "@/store";
  * ★ 只放**顾客页**,`/login` 仍走下面的精确清单 —— 它在已登录时要重定向到首页,
  *   语义不同,别合并。
  *
- * ★ 第二个名字 `HarCheckout` 是汇安融通道的同款顾客页(`/#/checkout/har/<token>`,
- *   路径多一段 `har` 字面量)。加它是一次**显式声明** —— 这正是上面第一条注释要的效果:
+ * ★ 第二个名字 `HarCheckout` 是汇安融通道的同款顾客页(`/#/checkout/alipay/<token>`,
+ *   路径多一段 `alipay` 字面量)。加它是一次**显式声明** —— 这正是上面第一条注释要的效果:
  *   按名字放行时,新公开页面必须有人在这里写一行,而不会因为路径恰好长得像就自动开放。
+ *   (路径段 2026-10-09 由 `har` 改为 `alipay`;**名字 `HarCheckout` 不动** ——
+ *     它是上面这条放行判据读的那个键,改名要把这里一起改,否则页面静默变回「跳登录」。)
  */
 const PUBLIC_ROUTE_NAMES = new Set(["Checkout", "HarCheckout"]);
 

+ 6 - 4
frontend/src/router/index.ts

@@ -52,12 +52,14 @@ export const constantRoutes: RouteRecordRaw[] = [
     component: () => import("@/views/checkout/index.vue"),
   },
   {
-    // 汇安融顾客页(收款链接 `/#/checkout/har/<token>` 的落点)—— 免登录,同上一段。
+    // 汇安融顾客页(收款链接 `/#/checkout/alipay/<token>` 的落点)—— 免登录,同上一段。
     // ★ 为什么是**三段**路径而乐收赢是两段:`HarCheckoutLinkService#buildUrl` 的
-    //   PATH_PREFIX 是 `/#/checkout/har/`,`har` 是路径里的**字面段**,不是参数。
-    //   若按乐收赢写成 `checkout/:token`,`har` 会被当成 token ⇒ 顾客页拿着 "har"
+    //   PATH_PREFIX 是 `/#/checkout/alipay/`,`alipay` 是路径里的**字面段**,不是参数。
+    //   若按乐收赢写成 `checkout/:token`,`alipay` 会被当成 token ⇒ 顾客页拿着 "alipay"
     //   去查链接,一律「链接无效」。
-    path: "/checkout/har/:token",
+    //   (路径段 2026-10-09 由 `har` 改来 —— 这串出现在顾客眼前,不带上游品牌;
+    //     与后端 PATH_PREFIX 必须**同名**改,改单边就是整页 404。)
+    path: "/checkout/alipay/:token",
     name: "HarCheckout",
     meta: { hidden: true, title: "收款" },
     component: () => import("@/views/checkout/har/index.vue"),

+ 28 - 0
frontend/src/utils/shop-code.ts

@@ -0,0 +1,28 @@
+import dayjs from "dayjs";
+
+/**
+ * 门店编号(third_shop_id)自动生成 —— 汇安融门店创建表单用。
+ *
+ * 形状:`SP` + `yyyyMMddHHmmss` + 16 位随机,共 32 字符,正好吃满
+ * `third_shop_id` 的 varchar(32)。改成自动生成是因为运营手填这个编号的代价不对称:
+ * 撞号 ⇒ 上游按「重复提交」拒绝建店;填错一个字符 ⇒ 上游没有更新接口,事后补不上。
+ *
+ * 两个刻意的选择:
+ * - 字母表去掉 0/1/I/O(连同数字段共 32 个字符):编号要能在电话/微信里念得出来,
+ *   去掉易混字符,一个歧义就少一类抄错。
+ * - 32 是 2 的幂 ⇒ 取随机字节的低 5 位做下标是无偏的(256 % 32 == 0),无需拒绝采样。
+ *   随机源必须是 crypto.getRandomValues:这个编号在上游是**幂等键**,撞号等于
+ *   整笔建店被拒,Math.random 的碰撞概率和可预测性都不能接受。
+ */
+const ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
+
+export function generateShopCode(now: Date = new Date()): string {
+  const stamp = dayjs(now).format("YYYYMMDDHHmmss");
+  const bytes = new Uint8Array(16);
+  crypto.getRandomValues(bytes);
+  let rand = "";
+  for (const b of bytes) {
+    rand += ALPHABET[b & 31];
+  }
+  return `SP${stamp}${rand}`;
+}

+ 5 - 5
frontend/src/views/checkout/har/index.vue

@@ -112,12 +112,12 @@
 
 <script setup lang="ts">
 /**
- * 汇安融顾客页 —— 收款链接 `/#/checkout/har/<token>` 的落点。**免登录**。
+ * 汇安融顾客页 —— 收款链接 `/#/checkout/alipay/<token>` 的落点。**免登录**。
  * 逐条对标乐收赢同款页面(`views/checkout/index.vue`),差异只有下面这几处:
  *
  * 【与乐收赢那份的差异(都是通道契约,不是风格)】
- *   · 接口前缀 `/payment/har/checkout/...`;路由多一段 `har` **字面量**
- *     (后端 `HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX 是 `/#/checkout/har/`);
+ *   · 接口前缀 `/payment/har/checkout/...`;路由多一段 `alipay` **字面量**
+ *     (后端 `HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX 是 `/#/checkout/alipay/`);
  *   · 金额单位是**元**(乐收赢是分):提交发 `{ price: <元> }`,回显读 `price`;
  *     HAR 全通道如此(同收银台/开放 API)。
  *   · 下单应答里是 `pay_url` / `order_no`(乐收赢是 `code_url` / `out_trade_no`)。
@@ -135,8 +135,8 @@
  * 所以这里自建一个实例,**不挂任何拦截器**,每个响应都自己看 `code`。
  *
  * 【为什么是 hash 路由】`createWebHashHistory` —— 后端拼的链接形如
- * `<base>/#/checkout/har/<token>`(`HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX)。
- * 拼成 `/checkout/har/<token>` 会命中静态路由表末尾的通配 404。
+ * `<base>/#/checkout/alipay/<token>`(`HarCheckoutLinkService#buildUrl` 的 PATH_PREFIX)。
+ * 拼成 `/checkout/alipay/<token>` 会命中静态路由表末尾的通配 404。
  */
 defineOptions({
   name: "HarCheckout",

+ 4 - 4
frontend/src/views/module_payment/apikey/docs/collection-openapi.md

@@ -24,13 +24,13 @@
 | 退款 | 退款查询 | `POST /payment/openapi/pay/refund/query` |
 | 收款 | 收款链接 —— 生成一条**顾客自填金额**的收款页链接(桌牌 / 菜单用) | `POST /payment/openapi/pay/checkout/link` |
 
-**门店与订单通道 —— 路径前缀 `/payment/openapi/har`(详解见 10)**
+**门店与订单通道 —— 路径前缀 `/payment/openapi/alipay`(详解见 10)**
 
 | 能力 | 场景 | 接口 |
 |---|---|---|
-| 门店 | 创建门店 | `POST /payment/openapi/har/shop/create` |
-| 收款 | 下单收款(被扫 / 主扫由 `auth_code` 决定) | `POST /payment/openapi/har/order/create` |
-| 查询 | 订单查询 | `POST /payment/openapi/har/order/query` |
+| 门店 | 创建门店 | `POST /payment/openapi/alipay/shop/create` |
+| 收款 | 下单收款(被扫 / 主扫由 `auth_code` 决定) | `POST /payment/openapi/alipay/order/create` |
+| 查询 | 订单查询 | `POST /payment/openapi/alipay/order/query` |
 
 所有接口均为 **POST + JSON**,请求体必须带签名。两条通道的**认证与签名算法完全相同**(第 2、3 章对两者都适用),但**响应信封、金额单位、状态取值不同** —— 跨通道搬代码前,请先读 10 章开头的那张差异表。
 

+ 58 - 144
frontend/src/views/module_payment/apikey/docs/har-openapi.md

@@ -1,23 +1,8 @@
 ## 10. 门店与订单接口
 
-本章是**另一条通道**,路径前缀 `/payment/openapi/har`,与 5 章的收款接口不是同一个接口族。
+本章接口路径前缀 `/payment/openapi/alipay`;请求头与签名算法(3.2 / 3.3)逐字适用,错误处理总则(3.6)同样适用。
 
-**相同的部分**:请求头与签名算法(3.2 / 3.3 逐字适用)、错误处理总则(3.6)。
-
-**不同的部分 —— 动手前先读完这张表**:
-
-| 项 | 5 章收款接口 | 本章 |
-|---|---|---|
-| 路径前缀 | `/payment/openapi/pay` | `/payment/openapi/har` |
-| 响应信封 | 三个下单接口 A 型,其余 B 型 | **4 个接口全部 B 型** |
-| 金额 | 整数,单位**分** | 小数,单位**元** |
-| 订单状态 | 文本枚举(`SUCCESS` / `PAYING`…),见 6 | **数字字符串**(`"0"` / `"1"`…),见 10.6 |
-| 下单幂等 | 同 `out_trade_no` 重发返回原单 | **不幂等**,每次调用都新建一笔,见 10.2 |
-| 退款 | 有(5.6 / 5.7),可指定金额 | **只有全额退款**,没有金额可填,见 10.4 |
-
-> ⚠️ **「不幂等」与「金额单位是元」这两条最容易出事。** 前者会让你在超时重发时收到两笔扣款,后者会把 1 元写成 1 分。请读完 10.2 的注意框再动手。
-
-**关于寻址**:本章四个接口都按**你这条 API Key 所属的租户账号**寻址,取该租户名下由平台运营报备好的商户配置。你不需要在请求里指定归属。
+**关于寻址**:本章三个接口都按**你这条 API Key 所属的租户账号**寻址,取该租户名下由平台运营报备好的商户配置。你不需要在请求里指定归属。
 
 **关于 `merchant_code`(新增,2026-09-24)**:平台**允许一个租户报备多个商户号**(例如你名下有多家门店主体,各自独立结算)。这时「这笔钱收进哪一个商户」必须由你在请求里说清楚,用 `merchant_code` 指定。
 
@@ -26,141 +11,76 @@
 
 > ⚠️ **你租户下配了多个商户号、而你又不传 `merchant_code` 时,接口会直接**拒绝**(`code=400`,`msg` 为 `当前租户配置了多个商户编号,请在请求中指定 merchant_code`),不会替你挑一个。**这是有意的** —— 挑错了钱会进另一个商户的账户,而且全程没有任何报错,要等到对账才发现。**只配了一个商户号的调用方不受影响**,什么都不用改。
 
-> **怎么知道自己配了几个**:登录平台,在「商户配置」页可以看到你名下的全部商户号。四个接口都填同一个 `merchant_code` 即可。
+> **怎么知道自己配了几个**:登录平台,在「商户配置」页可以看到你名下的全部商户号。三个接口都填同一个 `merchant_code` 即可。
 
-**门店与下单的关系**:10.2 里的 `shop_code` / `third_shop_code` 都是**选填**。门店不是下单的前置条件 —— 只有需要把交易归属到某个具体门店时才填。
-
-> **但门店的「地区」是有用的**:平台的收款链接(第 6 章的顾客付款页)下单时会按顾客 IP 的归属地自动挑一家同地区的门店,见 10.1 的地区说明。你的接口自己下单时若指定了门店,那个地区的风控判断同样适用。
+**门店与下单的关系**:10.1 里的 `shop_code` / `third_shop_code` 都是**选填**。门店不是下单的前置条件 —— 只有需要把交易归属到某个具体门店时才填。
 
 ---
 
-### 10.1 创建门店
-
-为你的一个线下门店建号。平台侧**没有门店查询接口**,本接口的返回就是你日后核对门店状态的依据,请把 `shop_id` 存下来。
+### 10.1 下单收款
 
 ```
-POST /payment/openapi/har/shop/create
+POST /payment/openapi/alipay/order/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 之前建的门店**:那批门店没有地区,无法回填,**也不会被收款链接挑中**。让顾客扫码付款的门店必须重新建号。
+|---|---|----|---|
+| `merchant_code` | string | 否  | **你租户下配了多个商户号时必填**:这笔钱收进哪一个商户,见本章开头 |
+| `price` | number | 是  | 金额,**单位元**,必须 > 0,见下 |
+| `title` | string | 是  | 商品名称,≤64 字符 |
+| `out_biz_no` | string | 否  | 你自己的订单号,≤64 字符,原样存下供你查询 |
+| `shop_code` | string | 否  | 平台门店编号 |
+| `third_shop_code` | string | 否  | 门店编码 |
+| `ip` | string | 是  | 顾客用户端ip |
+| `pay_timeout` | int | 否  | 支付过期时间,取值 2–60(单位由上游定义,平台原样透传) |
 
 **响应**(B 型,`data` 内)
 
 | 字段 | 说明 |
 |---|---|
-| `third_shop_id` | 原样返回 |
-| `shop_name` | 原样返回 |
-| `province_code` / `city_code` | 原样返回(建号时提交的那两个码) |
-| `shop_id` | 平台门店编号,**建号成功才有值**;下单时作为 `shop_code` 传入 |
-| `create_status` | 建号结果,见下 |
-| `merchant_code` | 平台侧为你配置的商户编号(你无需关心,原样带回) |
-| `fail_reason` | `create_status` 非 `SUCCESS` 时的原因 |
-| `created_time` / `updated_time` | 记录时间 |
-
-`create_status` 取值:
-
-| 值 | 含义 | 你该做什么 |
-|---|---|---|
-| `SUCCESS` | 门店已建好,`shop_id` 可用 | 保存 `shop_id` |
-| `FAILED` | 明确被拒,不会建号 | 看 `fail_reason`,改好再调一次 |
-| `PENDING` | **结果未知** —— 调用超时或网络失败,无法确认门店是否已经建出来 | **不要直接重发**(可能建出两个门店),联系平台方人工核实 |
-
-> ⚠️ **`third_shop_id` 重复会被直接拒绝,而不是「返回原有门店」。** 重复提交同一个 `third_shop_id`,返回 `code=500`,`msg` 形如 `门店编码已存在: <你的编码>`。所以本接口的正确幂等做法是**先查你本地的记录,再决定要不要调**,而不是无脑重发。
-
-> ⚠️ **`PENDING` 不是「处理中」。** 门店创建**不自动重试** —— 重试可能在上游建出两个门店。收到 `PENDING` 时唯一正确的动作是找平台方核实,不要自行重发。
-
----
-
-### 10.2 下单收款
-
-同一个接口承担两种场景,由 `auth_code` 是否有值决定:
-
-| `auth_code` | 场景 | 行为 |
-|---|---|---|
-| **有值** | 被扫 —— 用户出示付款码,你扫码后把码传进来 | 立即发起扣款 |
-| **不传** | 主扫 —— 平台返回一条收款链接,你渲染成二维码给用户扫 | 只下单,等用户付款 |
-
-```
-POST /payment/openapi/har/order/create
-```
-
-**请求**
-
-| 字段 | 类型 | 必填 | 说明 |
-|---|---|---|---|
-| `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**:这笔钱收进哪一个商户,见本章开头 |
-| `price` | number | 是 | 金额,**单位元**,必须 > 0。与 5 章的「整数分」不同,见下 |
-| `title` | string | 是 | 商品名称,≤64 字符 |
-| `trade_type` | string | 是 | `alipay_un_stage` 支付宝支付 / `alipay_stage` 花呗分期 |
-| `biz` | string | 否 | 业务类型。**非分期传 `har_ali_un_stage`** |
-| `out_biz_no` | string | 否 | 你自己的订单号,≤64 字符,原样存下供你查询 |
-| `auth_code` | string | 否 | 用户的付款码。有值走被扫,不传走主扫 |
-| `stage_num` | string | 否 | 分期期数:`3` / `6` / `12` / `24`。`trade_type=alipay_stage` 时必填 |
-| `percent` | string | 否 | 分期手续费承担方:`100` 商户承担 / `0` 用户承担。`trade_type=alipay_stage` 时必填 |
-| `shop_code` | string | 否 | 平台门店编号(10.1 返回的 `shop_id`) |
-| `third_shop_code` | string | 否 | 你方门店编码,≤32 字符 |
-| `pay_timeout` | int | 否 | 支付过期时间,取值 2–60(单位由上游定义,平台原样透传) |
-| `extra_params` | string | 否 | 透传字段 |
-
-**响应**(B 型,`data` 内)
-
-| 字段 | 说明 |
-|---|---|
-| `order_no` | **平台订单号**,后续查询(10.3)用它 |
+| `order_no` | **平台订单号**,后续查询(10.2)用它 |
 | `out_biz_no` | 原样返回 |
 | `upstream_order_no` | 上游订单号。下单成功才有值,下单超时时为空 |
 | `platform_trade_no` | 平台交易号,支付成功后才有值 |
-| `pay_url` | **主扫**:收款链接,渲染成二维码即可。**被扫**:为空 |
-| `trade_status` | 订单状态,见 10.6 |
+| `pay_url` | 收款链接,渲染成二维码即可 |
+| `trade_status` | 订单状态,见 10.5 |
 | `price` | 订单金额(元) |
-| `title` / `trade_type` / `stage_num` / `percent` | 原样返回 |
-| `shop_code` / `third_shop_code` | 原样返回 |
+| `title` | 原样返回 |
+| `shop_code` / `third_shop_code` | 你传的门店编码原样返回 |
 | `trade_time` | 支付成功时间 |
 | `rcv_amount` | 实收金额(元),支付成功后有值 |
 | `fee_amount` / `channel_amount` | 手续费 / 渠道金额 |
 | `refund_time` | 退款时间。退款到账(`trade_status` 为 `"2"`)后才有值 |
-| `notify_status` | 平台向你推送回调的结果,见 10.5 |
+| `notify_status` | 平台向你推送回调的结果,见 10.4 |
 | `created_time` / `updated_time` | 记录时间 |
 
-> ⚠️ **本接口不幂等 —— 重复调用会产生两笔订单、两次扣款。** 每次调用都会生成一个新的 `order_no` 并向上游发起一笔新的交易,平台**不做** `out_biz_no` 去重(也不做唯一约束)。超时后不要直接重发:先用 **10.3 订单查询** 按 `out_biz_no` 查一次,查到就用那一笔的结果。
+> ⚠️ **本接口不幂等 —— 重复调用会产生两笔订单、两次扣款。** 每次调用都会生成一个新的 `order_no` 并向上游发起一笔新的交易,平台**不做** `out_biz_no` 去重(也不做唯一约束)。超时后不要直接重发:先用 **10.2 订单查询** 按 `out_biz_no` 查一次,查到就用那一笔的结果。
 
-> ⚠️ **`price` 的单位是「元」,是小数,不是「分」。** `1.5` 表示 1 元 5 角;5 章那一套要写 `total_amount=150` 才是同样的金额。**跨通道搬代码时这是最容易错的一处。**
+> ⚠️ **`price` 的单位是「元」,是小数,不是「分」。** `1.5` 表示 1 元 5 角;把「元」当「分」写会差 100 倍。
 
 > ⚠️ **金额的写法会直接影响签名。** 签名是对你**实际发出的那个值**做字符串化后算的(3.3),而平台把 JSON 数字读成双精度数:你写 `0.010`,平台看到的是 `0.01` —— 签名因此对不上,只会返回「签名验证失败」,不会告诉你原因。**请写成不带多余零的最简小数**(`0.01` / `1.5` / `100`),不要写 `0.010`、`1.50`。写成字符串(`"price": "0.01"`)也可以,字符串原样参与签名,最不容易出错。
 
-> ⚠️ **下单失败时本地记录仍在,不要当作「什么都没发生」。** 上游调用失败会以 `code=500` 返回(`msg` 形如 `下单失败: <原因>`),但平台**已经落了本地记录并排入了状态轮询** —— 这笔交易仍可能成功。收到失败后请用 10.3 确认,不要凭这一次返回就向用户宣称交易未发生。
+> ⚠️ **下单失败时本地记录仍在,不要当作「什么都没发生」。** 上游调用失败会以 `code=500` 返回(`msg` 形如 `下单失败: <原因>`),但平台**已经落了本地记录并排入了状态轮询** —— 这笔交易仍可能成功。收到失败后请用 10.2 确认,不要凭这一次返回就向用户宣称交易未发生。
 
 ---
 
-### 10.3 订单查询
+### 10.2 订单查询
 
 ```
-POST /payment/openapi/har/order/query
+POST /payment/openapi/alipay/order/query
 ```
 
 **请求**(`order_no` 与 `out_biz_no` **至少提供一个**;都提供时以 `order_no` 为准)
 
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
-| `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**,与上面两个接口同一个值。传了就只查这个商户的订单 |
-| `order_no` | string | 二选一 | 平台订单号(10.2 响应里的 `order_no`) |
+| `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**,与其它接口同一个值。传了就只查这个商户的订单 |
+| `order_no` | string | 二选一 | 平台订单号(10.1 响应里的 `order_no`) |
 | `out_biz_no` | string | 二选一 | 你自己的订单号 |
 
-**响应**(B 型,`data` 内)—— 字段与 10.2 的响应一致,另加:
+**响应**(B 型,`data` 内)—— 字段与 10.1 的响应一致,另加:
 
 | 字段 | 说明 |
 |---|---|
@@ -170,24 +90,24 @@ POST /payment/openapi/har/order/query
 
 > `order_no` 与 `out_biz_no` 都没传,返回 `code=500`,`msg` 为 `order_no 与 out_biz_no 至少填一个`。
 
-> ⚠️ **查不到订单时返回的是 `code=500`,不是 `404`。** 这与 5.4 的行为不同。`msg` 为 `订单不存在`。**不属于你的订单也返回同一个 `msg`** —— 平台有意不区分「不存在」与「不属于你」,避免被用来探测他人订单是否存在。
+> ⚠️ **查不到订单时返回的是 `code=500`,不是 `404`。** `msg` 为 `订单不存在`。**不属于你的订单也返回同一个 `msg`** —— 平台有意不区分「不存在」与「不属于你」,避免被用来探测他人订单是否存在。
 
 ---
 
-### 10.4 订单退款
+### 10.3 订单退款
 
 对一笔**已支付成功**的订单发起退款。
 
 ```
-POST /payment/openapi/har/refund
+POST /payment/openapi/alipay/refund
 ```
 
-**请求**(`order_no` 与 `out_biz_no` **至少提供一个**,规则同 10.3)
+**请求**(`order_no` 与 `out_biz_no` **至少提供一个**,规则同 10.2)
 
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---|---|
-| `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**,与其它三个接口同一个值 |
-| `order_no` | string | 二选一 | 平台订单号(10.2 响应里的 `order_no`) |
+| `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**,与其它接口同一个值 |
+| `order_no` | string | 二选一 | 平台订单号(10.1 响应里的 `order_no`) |
 | `out_biz_no` | string | 二选一 | 你自己的订单号 |
 | `reason` | string | 否 | 退款原因。**留空时平台补一个默认值**(`商户申请退款`);填了就以你填的为准 |
 
@@ -195,20 +115,20 @@ POST /payment/openapi/har/refund
 
 > ℹ️ **`reason` 为什么是选填**:通道上游把它列为必填,但没公布长度上限,所以平台不强制你填 —— 留空就替你补一个默认值,不会因此失败。想在自己的账上区分退款来源就填上。
 
-**响应**(B 型,`data` 内):结构与 10.3 完全相同(同一份订单)。
+**响应**(B 型,`data` 内):结构与 10.2 完全相同(同一份订单)。
 
 > ⚠️ **返回时 `trade_status` 是 `"4"`(退款中),不是 `"2"`。** 同步返回只代表平台已把退款指令发给上游,**不代表钱已经退回用户账上**。
 
-**结果怎么收敛**:`"4"` 由平台的查询任务持续向上游确认,直到拿到终态(上游若推了通知,平台也会转推给你,见 10.5):
+**结果怎么收敛**:`"4"` 由平台的查询任务持续向上游确认,直到拿到终态(上游若推了通知,平台也会转推给你,见 10.4):
 
 | 最终 `trade_status` | 含义 |
 |---|---|
 | `"2"` | **退款成功,钱已退回** |
 | `"1"` | 退款**没有发生**,订单回到已支付 |
 
-> ⚠️ **不要把 `"4"` 当成已退款,也不要凭它给用户承诺到账。** 用户看到的是钱有没有回来,不是接口返回了什么。同 10.5 的提醒:通知可能丢,**请对停在 `"4"` 的订单做主动查询兜底**。
+> ⚠️ **不要把 `"4"` 当成已退款,也不要凭它给用户承诺到账。** 用户看到的是钱有没有回来,不是接口返回了什么。同 10.4 的提醒:通知可能丢,**请对停在 `"4"` 的订单做主动查询兜底**。
 
-> ⚠️ **只有 `trade_status = "1"` 的订单能退款。** 其它状态(`"0"` / `"4"` / `"2"` / `"8"`)一律拒绝,`code=500`,`msg` 形如 `订单当前状态不可退款: <状态值>`。查不到订单、或不是你的订单,仍然是 10.3 那句 `code=500` 的 `订单不存在`。
+> ⚠️ **只有 `trade_status = "1"` 的订单能退款。** 其它状态(`"0"` / `"4"` / `"2"` / `"8"`)一律拒绝,`code=500`,`msg` 形如 `订单当前状态不可退款: <状态值>`。查不到订单、或不是你的订单,仍然是 10.2 那句 `code=500` 的 `订单不存在`。
 
 **重复调用会怎样** —— 这是最容易误解的一处:
 
@@ -219,8 +139,8 @@ POST /payment/openapi/har/refund
 
 | 返回 | 订单状态 | 你该做什么 |
 |---|---|---|
-| `code=500`,`msg` 形如 `退款失败: <原因>` | **退回 `"1"`** | 上游明确拒绝了,什么都没发生。可用 10.3 确认状态是 `"1"` 后**重新发起** |
-| `code=500`,`msg` 为 `退款结果未知(上游未响应),订单已置为退款中,请稍后查询订单状态确认` | **保持 `"4"`** | 上游没应答,**这笔退款可能已经发生**。**不要重发** —— 用 10.3 查到终态为止 |
+| `code=500`,`msg` 形如 `退款失败: <原因>` | **退回 `"1"`** | 上游明确拒绝了,什么都没发生。可用 10.2 确认状态是 `"1"` 后**重新发起** |
+| `code=500`,`msg` 为 `退款结果未知(上游未响应),订单已置为退款中,请稍后查询订单状态确认` | **保持 `"4"`** | 上游没应答,**这笔退款可能已经发生**。**不要重发** —— 用 10.2 查到终态为止 |
 
 > ⚠️ **两条路都是 `code=500`,只能靠 `msg` 区分。** 拿不准时一律按「结果未知」处理(不重发、去查询):重发一笔可能已经退掉的款是真实资金损失,多查一次只是麻烦。
 
@@ -228,11 +148,11 @@ POST /payment/openapi/har/refund
 
 ---
 
-### 10.5 回调通知
+### 10.4 回调通知
 
 订单状态变化时,平台会向你推送一条回调。
 
-**回调地址取自你这把 API Key 上配置的 `return_url`**(未配置时退回该租户的默认回调地址)。**没配就不会推送**,此时只能靠主动查询(10.3)拿结果。
+**回调地址取自你这把 API Key 上配置的 `return_url`**(未配置时退回该租户的默认回调地址)。**没配时平台不会丢下这笔** —— 记一次推送失败并继续重试(见下),你补配地址后未达上限的订单仍会收到;但尽快补配,超过上限就不会再推了,届时只能靠主动查询(10.2)拿结果。
 
 ```
 POST <你的 return_url>
@@ -251,7 +171,7 @@ Content-Type: application/x-www-form-urlencoded
 
 ```json
 {
-  "third_order_no": "HAR1234567890",
+  "third_order_no": "AP1234567890",
   "out_biz_no": "T20260924001",
   "order_no": "2026092499001",
   "platform_trade_no": "2026092499002",
@@ -259,7 +179,6 @@ Content-Type: application/x-www-form-urlencoded
   "third_shop_code": "S001",
   "price": 1.50,
   "title": "测试商品",
-  "trade_type": "alipay_un_stage",
   "status": "1",
   "trade_time": "2026-09-24T10:30:00+08:00",
   "refund_time": null,
@@ -271,9 +190,9 @@ Content-Type: application/x-www-form-urlencoded
 
 金额字段是 **JSON 数字**(不是字符串);时间字段是 **ISO-8601 带时区偏移**的字符串。
 
-> ⚠️ **回调里的 `order_no` 与 10.2 响应里的 `order_no` 不是同一个号。**
-> 回调里的 `order_no` 是**上游订单号**(等于 10.2 响应里的 `upstream_order_no`);
-> 回调里的 `third_order_no` 才是**平台订单号**(等于 10.2 响应里的 `order_no`)。
+> ⚠️ **回调里的 `order_no` 与 10.1 响应里的 `order_no` 不是同一个号。**
+> 回调里的 `order_no` 是**上游订单号**(等于 10.1 响应里的 `upstream_order_no`);
+> 回调里的 `third_order_no` 才是**平台订单号**(等于 10.1 响应里的 `order_no`)。
 > 名字是反的,但对账必须靠它们,请按这层对应关系取用。
 > **最省事的做法:始终用你自己生成的 `out_biz_no` 作主键做幂等,不要依赖这两个 `order_no`。**
 
@@ -281,17 +200,17 @@ Content-Type: application/x-www-form-urlencoded
 
 1. **应答**:处理成功后返回 **HTTP 2xx**。返回非 2xx 视为投递失败。
 2. **去重**:同一笔订单可能收到多次通知。请用 `out_biz_no` + `status` 做幂等。
-3. **快速应答**:先应答再异步处理。平台最多尝试 3 次(失败后隔 1 秒、2 秒各重试一次),收到 4xx 时不再重试。
+3. **快速应答**:先应答再异步处理。平台**首推只发一次**,失败(你方返回非 2xx、超时或连接异常)由平台自动重试:约每分钟一轮,累计尝试最多 **8 次(含首推)**;始终失败则停止,该单 `notify_status` 停在 `FAILED`。
 
-> ⚠️ **平台只在收到上游通知时才会向你推送回调。** 若上游那条通知丢了,平台会靠自己的任务把本地状态收敛正确,但**不会**补推一条给你 —— 此时 10.3 能查到正确结果,你的回调却永远不会响。**请务必给长期停在 `"0"` 的订单做主动查询兜底。**
+> ⚠️ **推送的触发不止「上游通知到达」这一种。** 平台在**本地订单状态收敛后**推送:既包括收到上游通知,也包括平台自己的查询任务发现的状态变化(上游通知丢了的那条腿)。但重试有上限(见上)—— 超过上限就不会再推。**请仍给长期停在 `"0"` 的订单做主动查询兜底**,别把回调当作唯一渠道。
 
 > ⚠️ **退款的最终结果(`"4"` → `"2"` / `"1"`)走的是同一条回调**,`content.status` 里就是新状态,`refund_time` 也在里面。所以上面的兜底对退款**同样适用,而且更要紧**:退款停在 `"4"` 时你等的是钱有没有回来,回调不响就只能自己查。**给长期停在 `"4"` 的订单也做主动查询。**
 
-> ⚠️ **回调不带签名。** 与 7.2 的情况相同 —— 不能仅凭收到回调就认定收款成功。请以 **10.3 订单查询** 的结果为准,把回调当作「该查了」的触发信号。
+> ⚠️ **回调不带签名。** 不能仅凭收到回调就认定收款成功。请以 **10.2 订单查询** 的结果为准,把回调当作「该查了」的触发信号。
 
 ---
 
-### 10.6 状态说明
+### 10.5 状态说明
 
 **`trade_status` 是数字字符串**(JSON 里带引号,不是数字):
 
@@ -304,33 +223,28 @@ Content-Type: application/x-www-form-urlencoded
 | `"8"` | 交易关闭 | 是 |
 
 判断「是否收到过钱」用 `"1"` / `"2"`;判断「是否还要等」用 `"0"` / `"4"`。
-判断「退款是否已经到账」只看 `"2"` —— 停在 `"4"` 表示平台还在向上游确认,**不代表已退**(见 10.4)。
-
-**门店 `create_status`**:`SUCCESS` / `FAILED` / `PENDING`,见 10.1。
+判断「退款是否已经到账」只看 `"2"` —— 停在 `"4"` 表示平台还在向上游确认,**不代表已退**(见 10.3)。
 
 **订单 `notify_status`**(平台向你推送回调的结果):
 
 | 值 | 含义 |
 |---|---|
-| `PENDING` | 尚未推送成功(含「你没有配置回调地址」这种情况) |
+| `PENDING` | 尚未发起过推送(订单刚生成 / 状态刚变化,推送还没回写结果) |
 | `SUCCESS` | 你方已成功接收 |
-| `FAILED` | 推送失败(对方返回非 2xx,或 3 次尝试均失败) |
+| `FAILED` | 推送失败(你方返回非 2xx、超时异常,或你还没配置回调地址)。平台会自动重试,累计最多 8 次(含首推)后停止;补配地址后未达上限的订单仍会被重推 |
 
 ---
 
 ## 11. 门店与订单通道的联调检查清单
 
 - [ ] 认证与签名复用 3.3 的实现,先跑通 3.4 的样例
-- [ ] 建号带了 `province_code` / `city_code`(GB/T 2260),且市码属于该省码
 - [ ] `price` 用**元**、写成最简小数(不要 `1.50`、`0.010`)
-- [ ] 下单**不重发** —— 超时先查 10.3,确认没有再决定
-- [ ] `auth_code` 有值走被扫、不传走主扫,两种都测过
-- [ ] 主扫拿到了 `pay_url` 并能渲染成二维码
+- [ ] 下单**不重发** —— 超时先查 10.2,确认没有再决定
+- [ ] 拿到了 `pay_url` 并能渲染成二维码
 - [ ] 按 `trade_status` 的**数字字符串**分支处理(`"1"` 才是钱到账)
 - [ ] 回调接口能处理表单编码,并把 `content` **再解析一次** JSON
 - [ ] 回调按 `out_biz_no` 做幂等
 - [ ] 对长期停在 `"0"` 的订单有主动查询兜底
-- [ ] 退款只对 `trade_status = "1"` 的订单发起,且发起前用 10.3 确认过没有退过
-- [ ] 退款返回 `退款结果未知…` 时**不重发**,改用 10.3 查到终态
+- [ ] 退款只对 `trade_status = "1"` 的订单发起,且发起前用 10.2 确认过没有退过
+- [ ] 退款返回 `退款结果未知…` 时**不重发**,改用 10.2 查到终态
 - [ ] 退款结果按 `"2"`(已到账)判定,不把 `"4"` 当已退款
-- [ ] 门店 `create_status` 收到 `PENDING` 时走人工核实,不自行重发

+ 2 - 3
frontend/src/views/module_payment/apikey/harDoc.ts

@@ -11,10 +11,9 @@ import { buildSections, type DocSectionDef } from "./docSlicer";
  * 侧边栏条目定义。键即 `activeSection` 的值。
  * `parts` 里的标题必须与 md 里的标题**逐字一致**(含空格、全角括号)。
  *
- * 整章只给一个条目,不再拆成 10.1~10.6 —— 章首那张差异表(金额单位、幂等性)
- * 必须先读,拆开会让读者直接跳到 10.2 下单。
+ * 整章只给一个条目,不拆成 10.1~10.5(各小节互相引用,拆开会让读者跳读)。
  * 注意:`take("## 10. …")` 会一并吞掉其下所有 `###` 子块,所以这里**不能**改写成
- * 「## 10 + 各 ### 分开列」—— 那样差异表会被重复渲染五次。
+ * 「## 10 + 各 ### 分开列」—— 那样各小节会被重复渲染。
  */
 const SECTIONS: DocSectionDef[] = [
   {

+ 18 - 5
frontend/src/views/module_payment/har/shop/index.vue

@@ -154,7 +154,7 @@
         type="info"
         :closable="false"
         show-icon
-        title="门店编码提交后即为该门店的唯一标识,重复提交会被后台拒绝,请勿反复尝试。"
+        title="门店编码由系统自动生成,每次打开弹窗都会换新;提交后即为该门店在上游的唯一标识,不可修改、不可撤回。提示「待核实」时请勿重开重提 —— 重开会换新编号,重提等于再建一家,请等待人工核对结果。"
       />
       <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
         <el-form-item label="商户" prop="merchant_code">
@@ -197,12 +197,22 @@
           </el-select>
         </el-form-item>
         <el-form-item label="门店编码" prop="third_shop_id">
+          <!-- 自动生成(2026-10-09):编号在上游是**幂等键** —— 撞号等于整笔建店被拒,
+               手填还容易抄错;而上游没有门店更新接口,错一个字符这家店就补不回来。
+               只读展示、不改动。
+               ★ openCreate 每次打开都重新生成:拿到「待核实」(PENDING)后关掉重开就换了号,
+               重提等于又建一家。若要改成「成功前保留同一编号」,得同时设计换店时的
+               重新生成入口 —— 否则只读格 + 无「重新生成」按钮会把想换一家店的运营卡死。 -->
           <el-input
             v-model="form.third_shop_id"
-            placeholder="商户侧门店唯一编码"
+            readonly
+            placeholder="打开时自动生成"
             maxlength="32"
-            show-word-limit
           />
+          <div class="form-tip">
+            编号由系统自动生成(SP + 时间 + 随机段),一旦提交不可再改 ——
+            上游没有门店更新接口。
+          </div>
         </el-form-item>
         <el-form-item label="门店名称" prop="shop_name">
           <el-input v-model="form.shop_name" maxlength="128" show-word-limit />
@@ -297,6 +307,7 @@ import HarAPI, {
   type HarShopVO,
 } from "@/api/module_payment/har";
 import { useEnterpriseStore } from "@/store/modules/enterprise.store";
+import { generateShopCode } from "@/utils/shop-code";
 
 const enterpriseStore = useEnterpriseStore();
 
@@ -350,7 +361,7 @@ async function ensureCities(code?: string | null) {
 }
 
 function onProvinceChange(code: string) {
-  // 换省必须把市清掉:留着一个属于上一省的市码,提交出去就是「省 32 + 市 1101」,
+  // 换省必须把市清掉:留着一个属于上一省的市码,提交出去就是「省 320000 + 市 110100」,
   // 后端按省查表会拒(正确),但用户看到的是自己明明选过城市
   form.city_code = "";
   ensureCities(code);
@@ -455,7 +466,7 @@ async function load() {
     list.value = res.data.data?.items || res.data.data?.list || [];
     total.value = res.data.data?.total || 0;
     // 本页各行涉及的省各拉一次市(有缓存就不重复拉)——列表里要把码显示成名,
-    // 缺这一句时每行只能显示编码,运营得自己背 3201 是哪个市
+    // 缺这一句时每行只能显示编码,运营得自己背 320100 是哪个市
     ensureProvinces();
     new Set(list.value.map((row) => row.province_code).filter((code): code is string => !!code))
       .forEach((code) => ensureCities(code));
@@ -479,6 +490,8 @@ function handleReset() {
 
 function openCreate() {
   Object.assign(form, defaultForm());
+  // 每次打开都生成新号:编号在上游是幂等键,手填易撞号/抄错,且事后补不上
+  form.third_shop_id = generateShopCode();
   createVisible.value = true;
   // 目录是异步来的(也只在真正建店时用得上),别放进 onMounted 让列表页白等
   ensureProvinces();

+ 5 - 2
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -45,8 +45,11 @@ public class SecurityConfig {
             // 万一 filter 因路径或装配问题漏跑,请求会走到 Controller 拿明确响应,
             // 而不是被 AuthorizationFilter 丢一个空 403。
             "/payment/openapi/pay/**",
-            // 汇安融开放接口:同上,由 TenantApiKeyAuthFilter 独立认证
-            "/payment/openapi/har/**",
+            // 汇安融开放接口:同上,由 TenantApiKeyAuthFilter 独立认证。
+            // ★ 前缀词 2026-10-09 由 har 改为 alipay(对调用方去上游品牌)——
+            //   必须与 TenantApiKeyAuthFilter.OPENAPI_PREFIXES 里那条**同名**改,
+            //   只改一处就是「被放行 + 无人认证」的零鉴权(2026-09-24 踩过)。
+            "/payment/openapi/alipay/**",
             "/system/auth/login",
             "/system/auth/login/mini",
             "/system/auth/login/sms",

+ 10 - 7
java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java

@@ -80,7 +80,7 @@ import java.util.UUID;
  *
  * ———————————————————————————————————————
  * 作用范围:
- *   拦截 /payment/openapi/account/**、/payment/openapi/pay/** 与 /payment/openapi/har/**
+ *   拦截 /payment/openapi/account/**、/payment/openapi/pay/** 与 /payment/openapi/alipay/**
  *   (前缀判定, 见 OPENAPI_PREFIXES)
  *   /payment/openapi/conf 等管理接口走 JWT 认证, 不经过此 filter
  */
@@ -112,10 +112,11 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
             "/payment/openapi/pay/refund/query",
             // 汇安融开放接口(门店/订单/退款)。同上:本数组只供核对,认证判定读 OPENAPI_PREFIXES。
             // 本通道这份清单由 HarOpenControllerTest#everyEndpoint_isListedInTheAuthFilterInventory 核对。
-            "/payment/openapi/har/shop/create",
-            "/payment/openapi/har/order/create",
-            "/payment/openapi/har/order/query",
-            "/payment/openapi/har/refund",
+            // 前缀词 2026-10-09 由 har 改为 alipay(对调用方去上游品牌,见下面 OPENAPI_PREFIXES 的注)。
+            "/payment/openapi/alipay/shop/create",
+            "/payment/openapi/alipay/order/create",
+            "/payment/openapi/alipay/order/query",
+            "/payment/openapi/alipay/refund",
     };
 
     /**
@@ -140,12 +141,14 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
             //   HAR 那一侧自己没带 —— 它只把 3 条端点加进了上面那份逐条清单,
             //   而**本数组才是认证判定读的那个**(见本数组第 1 条 javadoc)。
             //   不补的后果不是「少一层保护」而是**零鉴权**:SecurityConfig.WHITE_LIST 已
-            //   permitAll 掉 /payment/openapi/har/**,filter 又不认这个前缀 ⇒ 该前缀下
+            //   permitAll 掉 /payment/openapi/alipay/**,filter 又不认这个前缀 ⇒ 该前缀下
             //   没有任何 filter 认证,且不会有测试变红。
+            //   (前缀词 2026-10-09 由 har 改成 alipay —— 对调用方去上游品牌。白名单那条
+            //     与这份前缀必须**同名**改:只改一处就重新落回上面那个零鉴权形态。)
             //   HAR 的管理端在 /payment/har/**(不走 openapi)、通知在 /payment/notify/har,
             //   都不被本前缀覆盖;这条边界由 LeshouyingAdminWiringTest 的
             //   adminPaths_mustNotBeCoveredByApiKeyFilterPrefixes 动态守住。
-            "/payment/openapi/har",
+            "/payment/openapi/alipay",
     };
 
     private static final String AUTH_PREFIX = "ApiKey ";

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

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

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

@@ -59,6 +59,14 @@ public class HarProperties {
     /** 异步回调企业商户的线程数上限 */
     private int callbackPoolSize = 4;
 
+    /**
+     * 下游回调最大尝试次数(含首次)。
+     *
+     * <p>推送失败 / 缺地址落 FAILED 后由调度器按此上限重试
+     * (对齐乐收赢 {@code leshouying.downstream-notify-max-retries} 的默认 8)。
+     */
+    private int callbackMaxRetries = 8;
+
     /** 连接超时(毫秒) */
     private int connectTimeoutMs = 5000;
 

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/har/controller/HarOrderController.java

@@ -73,7 +73,7 @@ public class HarOrderController {
      * 退款(全额,调用上游)。
      *
      * <p>这是本控制器第一个**动钱**的端点,也是本通道管理端唯一的退款入口 ——
-     * 在此之前只有开放 API 能退({@code POST /payment/openapi/har/refund})。
+     * 在此之前只有开放 API 能退({@code POST /payment/openapi/alipay/refund})。
      *
      * <p>请求体可省:只为了带一句原因,留空由服务端补默认值(上游要求非空)。
      * 权限串**不复用** sync / create:能查一笔单不等于能动这笔钱。

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

@@ -70,7 +70,7 @@ public class HarRegionController {
     /**
      * 某省的地级列表。
      *
-     * @param provinceCode 两位省码,必须是上面那个列表里给出的值
+     * @param provinceCode 省码(GB/T 2260 六位,形如 {@code 320000}),必须是上面那个列表里给出的值
      * @throws BusinessException 400 —— 空白或不在目录里(**不回空表**,理由见类 javadoc)
      */
     @GetMapping("/cities")

+ 2 - 2
java/src/main/java/com/payment/platform/module/payment/har/controller/open/HarOpenController.java

@@ -40,9 +40,9 @@ import org.springframework.web.bind.annotation.RestController;
  * 不带而该租户又有多条时直接报 400,不由系统挑(见 {@link #requireMerchant})。
  */
 @Slf4j
-@Tag(name = "汇安融-开放API")
+@Tag(name = "支付宝收单-开放API")
 @RestController
-@RequestMapping("/payment/openapi/har")
+@RequestMapping("/payment/openapi/alipay")
 @RequiredArgsConstructor
 public class HarOpenController {
 

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/har/dto/HarCheckoutLinkVO.java

@@ -32,7 +32,7 @@ public class HarCheckoutLinkVO {
     private String merchantName;
 
     /**
-     * 完整收款链接(形如 {@code https://<域名>/#/checkout/har/<token>})。
+     * 完整收款链接(形如 {@code https://<域名>/#/checkout/alipay/<token>})。
      *
      * <p>★ 基址由 {@code HarCheckoutLinkService#buildUrl} 推导(配置优先,否则按当前请求的
      * Host 推导)—— 控制器**不自己拼**:那是第二个副本,而两处漂移的后果是

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

@@ -42,18 +42,20 @@ public class HarOrderCreateDTO {
     private String title;
 
     /**
-     * alipay_stage:花呗分期  alipay_un_stage:支付宝支付。
+     * alipay_stage:花呗分期  alipay_un_stage:支付宝支付。**选填**:留空 ⇒ 平台按
+     * {@code alipay_un_stage} 填充(2026-10-09 起从调用方契约里拿掉,开放文档不再列它)。
      *
-     * <p>非分期那个值 260924 之前叫 {@code alipay_payment},规范 260924 改成
-     * {@code alipay_un_stage}。原样透传,后端不校验取值。
+     * <p>显式传值仍原样透传、后端不校验取值 —— 分期与后台表单走这条路。
+     * 非分期那个值 260924 之前叫 {@code alipay_payment}。
      */
-    @NotBlank(message = "交易方式不能为空")
     private String tradeType;
 
     /**
-     * 业务类型。选填。规范 260924 订单创建参数表新增 biz 行:非分期传 {@code har_ali_un_stage}。
+     * 业务类型。选填。**留空即可** —— 平台按 {@code trade_type} 自动填
+     * (非分期 ⇒ {@code har_ali_un_stage})。
      *
-     * <p>同样是原样透传 —— 取值规则是给调用方的指示,不是后端要推导的东西。
+     * <p>2026-10-09 起由服务端派生(此前要求调用方照抄规范 260924 参数表的取值,
+     * 那串带上游品牌);显式传值时仍原样透传 —— 不校验取值,与 trade_type 同口径。
      */
     private String biz;
 
@@ -70,6 +72,25 @@ public class HarOrderCreateDTO {
     /** 平台门店编号 */
     private String shopCode;
 
+    /**
+     * 顾客所在省(选填,2026-10-09)。**只在没有传任何门店编码时有作用**:给了就由平台
+     * 按地区挑一家门店发起交易(挑店规则见 {@code HarShopService#pickForOrder})。
+     * GB/T 2260 省级六位码,如 "320000"(江苏省)、"110000"(北京市)—— 与门店创建的
+     * province_code 是**同一套码**(10.1 节)。
+     *
+     * <p>与 {@code ip} 二选一或同传:IP 能解析出归属地时**以 IP 为准**(更精确);
+     * IP 解析不出、或 IP 归属地没有可用门店时回退到本字段按全省挑。
+     */
+    private String provinceCode;
+
+    /**
+     * 顾客的公网 IP(选填,2026-10-09)。★ 传的是**顾客的** IP,不是服务器出口 IP。
+     *
+     * <p>平台用它解析顾客归属地、挑对应地区的门店交易(支付宝对异地收款有风控),
+     * 精度到市,优先于 province_code。可单独传、也可与 province_code 同传作为回退。
+     */
+    private String ip;
+
     /** 支付过期时间,2 到 60,单位以上游为准,本地原样透传 */
     private Integer payTimeout;
 

+ 5 - 3
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderVO.java

@@ -8,6 +8,11 @@ import lombok.Data;
 import java.math.BigDecimal;
 import java.time.OffsetDateTime;
 
+/**
+ * 订单应答 VO。**不含 biz**(2026-10-09 去上游化):那串取值带上游品牌,且自平台按
+ * {@code trade_type} 派生后调用方既不用填也不用回读(见 {@code HarOrderService#resolveBiz})。
+ * 实体与库表仍保留该列(内部实现;后台表单仍可显式传,是透传不是回读)。
+ */
 @Data
 public class HarOrderVO {
 
@@ -25,8 +30,6 @@ public class HarOrderVO {
     private BigDecimal price;
     private String title;
     private String tradeType;
-    /** 业务类型。非分期传 har_ali_un_stage(规范 260924 订单创建参数表 biz 行) */
-    private String biz;
     private String stageNum;
     private String percent;
     private Integer payTimeout;
@@ -62,7 +65,6 @@ public class HarOrderVO {
         vo.setPrice(e.getPrice());
         vo.setTitle(e.getTitle());
         vo.setTradeType(e.getTradeType());
-        vo.setBiz(e.getBiz());
         vo.setStageNum(e.getStageNum());
         vo.setPercent(e.getPercent());
         vo.setPayTimeout(e.getPayTimeout());

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

@@ -17,7 +17,7 @@ import lombok.Data;
 @Data
 public class HarRegionVO {
 
-    /** GB/T 2260 编码:省两位({@code 32}),地级四位({@code 3201}) */
+    /** GB/T 2260 编码,完整六位:省 {@code 320000},地级 {@code 320100} */
     private String code;
 
     /** 展示名(目录里那份,直辖市的地级是占位名换成的省名,见 HarRegionCatalog 的 javadoc) */

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

@@ -41,7 +41,7 @@ public class HarShopCreateDTO {
     private String enterpriseId;
 
     /**
-     * 省份编码,GB/T 2260 两位({@code 32} = 江苏省)。**必填**(2026-10-08 规范 5.5 起)。
+     * 省份编码,GB/T 2260 六位({@code 320000} = 江苏省)。**必填**(2026-10-08 规范 5.5 起)。
      *
      * <p>取值必须是 {@code HarRegionCatalog} 里有的码 —— 服务层会对表校验,
      * 因为编码写错的门店在收款链接里**永远挑不中**,而上游没有门店更新接口,事后补不上。
@@ -51,7 +51,7 @@ public class HarShopCreateDTO {
     @Size(max = 16, message = "省份编码最长 16 位")
     private String provinceCode;
 
-    /** 市编码,GB/T 2260 四位({@code 3201} = 南京市)。**必填**,且必须属于上面那个省。 */
+    /** 市编码,GB/T 2260 六位({@code 320100} = 南京市)。**必填**,且必须属于上面那个省。 */
     @NotBlank(message = "城市不能为空")
     @Size(max = 16, message = "城市编码最长 16 位")
     private String cityCode;

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

@@ -17,9 +17,9 @@ public class HarShopVO {
     private String merchantCode;
     private String thirdShopId;
     private String shopName;
-    /** GB/T 2260 两位省码;2026-10-08 之前建的门店为空 */
+    /** GB/T 2260 六位省码;2026-10-08 之前建的门店为空 */
     private String provinceCode;
-    /** GB/T 2260 四位市码;与 {@link #provinceCode} 同在 */
+    /** GB/T 2260 六位市码;与 {@link #provinceCode} 同在 */
     private String cityCode;
     private String shopId;
     private String createStatus;

+ 6 - 0
java/src/main/java/com/payment/platform/module/payment/har/entity/HarOrderEntity.java

@@ -100,6 +100,12 @@ public class HarOrderEntity extends PaymentEnterpriseBaseEntity {
     /** 回调企业商户的结果,见 HarNotifyStatus */
     private String notifyStatus;
 
+    /** 下游回调已尝试次数(含首次与「缺地址」的那次)—— 调度器重试的封顶计数 */
+    private Integer notifyCount;
+
+    /** 最近一次回调尝试时间 —— 重试扫描的排序键(饿最久的先推) */
+    private OffsetDateTime lastNotifyTime;
+
     @Version
     private Integer version;
 }

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

@@ -25,7 +25,7 @@ public class HarShopEntity extends PaymentEnterpriseBaseEntity {
     private String shopName;
 
     /**
-     * 所属省份编码,GB/T 2260 两位({@code 32} = 江苏省)。
+     * 所属省份编码,GB/T 2260 六位({@code 320000} = 江苏省)。
      *
      * <p>上游 {@code kf.har.shop.create} 自 2026-10-08 规范起把 province_code / city_code
      * 列为**必填**,且支付宝对异地收款有风控 —— 收款链接下单时要按顾客 IP 挑同地区门店,
@@ -36,7 +36,7 @@ public class HarShopEntity extends PaymentEnterpriseBaseEntity {
      */
     private String provinceCode;
 
-    /** 所属地级编码,GB/T 2260 四位({@code 3201} = 南京市)。见 {@link #provinceCode} */
+    /** 所属地级编码,GB/T 2260 六位({@code 320100} = 南京市)。见 {@link #provinceCode} */
     private String cityCode;
 
     /** 汇安融返回的门店 ID,未成功时为空 */

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/har/enums/HarNotifyStatus.java

@@ -3,7 +3,7 @@ package com.payment.platform.module.payment.har.enums;
 /** 回调企业商户的结果。 */
 public final class HarNotifyStatus {
 
-    /** 尚未回调(或租户未配置回调地址) */
+    /** 尚未回调(新单初始态;缺地址/推送失败都落 FAILED,不再停在这一档) */
     public static final String PENDING = "PENDING";
 
     public static final String SUCCESS = "SUCCESS";

+ 26 - 1
java/src/main/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollScheduler.java

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.har.scheduler;
 
 import com.payment.platform.module.payment.har.config.HarProperties;
 import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.service.HarCallbackService;
 import com.payment.platform.module.payment.har.service.HarOrderService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -25,10 +26,13 @@ public class HarOrderPollScheduler {
 
     private final HarOrderService orderService;
     private final HarProperties harProperties;
+    private final HarCallbackService callbackService;
 
-    public HarOrderPollScheduler(HarOrderService orderService, HarProperties harProperties) {
+    public HarOrderPollScheduler(HarOrderService orderService, HarProperties harProperties,
+                                 HarCallbackService callbackService) {
         this.orderService = orderService;
         this.harProperties = harProperties;
+        this.callbackService = callbackService;
     }
 
     @Scheduled(fixedDelay = 60 * 1000, initialDelay = 60 * 1000)
@@ -61,4 +65,25 @@ public class HarOrderPollScheduler {
         }
         log.info("[汇安融轮询] 完成: {}/{}", ok, orders.size());
     }
+
+    /**
+     * 失败回调重试(2026-10-09 补齐)。
+     *
+     * <p>独立任务而不是挂在 {@link #pollPendingOrders} 尾巴上:后者有空列表早退
+     * (没有待查订单是常态),挂在尾巴上会让重试连带被跳过。开闸条件与轮询一致
+     * ({@code har.enabled})—— 密钥没配好时连扫都不该扫。上限与排序在
+     * {@code HarCallbackService#retryFailed} 里(对齐乐收赢同款查询)。
+     */
+    @Scheduled(fixedDelay = 60 * 1000, initialDelay = 75 * 1000)
+    public void retryFailedCallbacks() {
+        if (!harProperties.isEnabled()) {
+            return;
+        }
+        try {
+            callbackService.retryFailed();
+        } catch (Exception e) {
+            // 重试整批炸了不能掀翻调度线程 —— 那上面还挂着轮询主任务(状态收敛的主路径)
+            log.error("[汇安融回调重试] 执行失败: {}", e.getMessage(), e);
+        }
+    }
 }

+ 97 - 67
java/src/main/java/com/payment/platform/module/payment/har/service/HarCallbackService.java

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.har.service;
 
 import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.payment.platform.common.utils.SnowflakeIdGenerator;
 import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity;
 import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
@@ -26,7 +27,9 @@ import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
 import org.springframework.web.client.RestTemplate;
 
+import java.time.OffsetDateTime;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
@@ -42,24 +45,24 @@ import java.util.concurrent.TimeUnit;
  * 回调地址解析也沿用 OpenapiService.resolveReturnUrl 的优先级:
  * sys_tenant_api_key.return_url → open_conf.return_url。
  *
- * 两点刻意的设计:
+ * 三点刻意的设计:
  *
  * 1. 用 CompletableFuture + 自有线程池而非 @Async —— 项目没有开 @EnableAsync,
  *    @Async 会静默地同步执行,把上游通知响应时间拖到 30 秒以上,直接超出上游
  *    25 秒的重试窗口。为了一个回调去开全局 @EnableAsync 又会改变其他地方的语义。
  *
- * 2. 回调失败只记日志并落 notify_status,不抛异常 —— 上游通知要求我们尽快返回
- *    success,不能被下游回调拖住。
+ * 2. 推送**单次**、失败只记录不抛 —— 上游通知要求我们尽快返回 success,不能被
+ *    下游回调拖住;线程内退避重试(旧实现睡 1s、2s 共 3 次)会占满回调池。
+ *    重试交给调度器 {@link #retryFailed}:有次数上限、不占回调线程。
+ *
+ * 3. 缺地址与推送失败**统一记 FAILED**(不再静默跳过)—— 重试扫描的入口条件
+ *    就是 notify_status='FAILED',只打日志的话,商户事后补配地址也永远等不到
+ *    这一笔(2026-10-09 补齐)。
  */
 @Slf4j
 @Service
 public class HarCallbackService {
 
-    private static final int MAX_ATTEMPTS = 3;
-
-    /** 退避秒数,与 OpenapiService.CALLBACK_BACKOFF_SECONDS 保持一致 */
-    private static final int[] BACKOFF_SECONDS = {1, 2};
-
     /** 回调报文的序列化器。显式注册 JavaTimeModule,保证 OffsetDateTime 输出 ISO 字符串 */
     private static final ObjectMapper MAPPER = new ObjectMapper()
             .registerModule(new JavaTimeModule())
@@ -71,6 +74,8 @@ public class HarCallbackService {
     private final RestTemplate restTemplate;
     /** 订单信号发布(订单实时推送)—— 回调状态(notify_status)落库后发。 */
     private final OrderEventPublisher publisher;
+    /** 回调尝试次数上限(含首次)—— 重试封顶对齐乐收赢 downstreamNotifyMaxRetries */
+    private final HarProperties harProperties;
     private final ExecutorService callbackPool;
 
     public HarCallbackService(TenantApiKeyMapper apiKeyMapper,
@@ -83,6 +88,7 @@ public class HarCallbackService {
         this.confMapper = confMapper;
         this.orderMapper = orderMapper;
         this.restTemplate = restTemplate;
+        this.harProperties = harProperties;
         this.publisher = publisher;
         int poolSize = Math.max(1, harProperties.getCallbackPoolSize());
         this.callbackPool = Executors.newFixedThreadPool(poolSize, r -> {
@@ -106,7 +112,9 @@ public class HarCallbackService {
     }
 
     /**
-     * 异步回调企业商户。方法立即返回,不阻塞上游通知的响应。
+     * 回调企业商户。有地址走异步推送(方法立即返回,不阻塞上游通知的响应);
+     * 没地址**记 FAILED 落库**(同步写,不占回调线程),让 {@link #retryFailed}
+     * 将来还能捞起它 —— 商户补配地址后照样能收到这一笔。
      */
     public void notifyEnterprise(HarOrderEntity order) {
         if (order == null) {
@@ -114,13 +122,14 @@ public class HarCallbackService {
         }
         String returnUrl = resolveReturnUrl(order.getTenantId(), order.getApiKey());
         if (StrUtil.isBlank(returnUrl)) {
-            log.info("[汇安融回调] 租户未配置回调地址,跳过: tenant_id={}, order_no={}",
+            log.warn("[汇安融回调] 租户未配置回调地址,记 FAILED 待重试: tenant_id={}, order_no={}",
                     order.getTenantId(), order.getOrderNo());
+            writeNotifyResult(order, false);
             return;
         }
 
         String orderNo = order.getOrderNo();
-        CompletableFuture.runAsync(() -> doNotify(order, returnUrl, orderNo), callbackPool)
+        CompletableFuture.runAsync(() -> writeNotifyResult(order, pushOnce(returnUrl, order)), callbackPool)
                 .exceptionally(ex -> {
                     log.error("[汇安融回调] 异步任务异常: order_no={}, error={}",
                             orderNo, ex.getMessage(), ex);
@@ -128,36 +137,37 @@ public class HarCallbackService {
                 });
     }
 
-    private void doNotify(HarOrderEntity order, String returnUrl, String orderNo) {
-        String notifyId = "n" + SnowflakeIdGenerator.nextIdStr();
-        long timestamp = System.currentTimeMillis();
-        String content = buildContent(order);
-
-        boolean ok = post(returnUrl, notifyId, timestamp, content);
-        writeNotifyStatus(orderNo, ok);
-    }
-
     /**
-     * 回写回调结果。
+     * 回写一次回调尝试的结果。
      *
-     * 重新查一次再更新,而不是直接 update 传入的实体 —— 传入的实体可能已被
-     * 通知线程后续的状态更新覆盖,用乐观锁直接更新会因 version 过期而失败。
+     * 【为什么是 UpdateWrapper 而不是「先查再 updateById」】
+     * ① 乐观锁竞态:本方法可能由回调线程在 {@code syncFromUpstream} 事务提交**前**
+     *    执行(轮询腿触发回调),先查拿到的是提交前的旧 version —— 事务提交后
+     *    updateById 的 {@code WHERE version=?} 被新版本挡掉,写库**静默丢**(0 行)。
+     *    免版本的 SET 更新直接排队等行锁,提交后落在新行上。
+     * ② notify_count 自增必须落在 SQL 侧:并发重试下 read-modify-write 会丢计数,
+     *    计数丢了次数上限就形同虚设。
+     * 代价是 updated_time 也要显式写({@code update(null, wrapper)} 不经自动填充,
+     * 同 {@code HarOrderService#doRefund} 先例)。
      */
-    private void writeNotifyStatus(String orderNo, boolean ok) {
+    private void writeNotifyResult(HarOrderEntity order, boolean ok) {
         try {
-            HarOrderEntity latest = orderMapper.selectOne(new LambdaQueryWrapper<HarOrderEntity>()
-                    .eq(HarOrderEntity::getOrderNo, orderNo)
-                    .last("LIMIT 1"));
-            if (latest == null) {
+            OffsetDateTime now = OffsetDateTime.now();
+            UpdateWrapper<HarOrderEntity> w = new UpdateWrapper<>();
+            w.set("notify_status", ok ? HarNotifyStatus.SUCCESS : HarNotifyStatus.FAILED)
+                    .setSql("notify_count = COALESCE(notify_count, 0) + 1")
+                    .set("last_notify_time", now)
+                    .set("updated_time", now)
+                    .eq("order_no", order.getOrderNo());
+            if (orderMapper.update(null, w) == 0) {
+                log.warn("[汇安融回调] 状态写入未命中订单: order_no={}", order.getOrderNo());
                 return;
             }
-            latest.setNotifyStatus(ok ? HarNotifyStatus.SUCCESS : HarNotifyStatus.FAILED);
-            orderMapper.updateById(latest);
             // 回调状态也是页面上的一格(HAR 详情弹窗),成功失败都要发。
-            // 本方法 private + 跑在回调线程池里(Task 7 唯一没被单测钉住的写点,Task 14 E2E 覆盖)。
-            publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, latest.getTenantId());
+            publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, order.getTenantId());
         } catch (Exception e) {
-            log.warn("[汇安融回调] 回写 notify_status 失败: order_no={}, error={}", orderNo, e.getMessage());
+            log.warn("[汇安融回调] 回写 notify_status 失败: order_no={}, error={}",
+                    order.getOrderNo(), e.getMessage());
         }
     }
 
@@ -212,44 +222,64 @@ public class HarCallbackService {
         }
     }
 
-    private boolean post(String url, String notifyId, long timestamp, String content) {
-        for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
-            try {
-                MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
-                formData.add("notify_id", notifyId);
-                formData.add("timestamp", String.valueOf(timestamp));
-                formData.add("content", content);
-
-                HttpHeaders headers = new HttpHeaders();
-                headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+    /**
+     * 单次推送 —— 线程内不再退避重试(重试见 {@link #retryFailed})。
+     *
+     * @return true = 商户侧返回 2xx;任何非 2xx / 异常都算一次失败尝试,记 FAILED 等调度器再试
+     */
+    private boolean pushOnce(String url, HarOrderEntity order) {
+        String notifyId = "n" + SnowflakeIdGenerator.nextIdStr();
+        MultiValueMap<String, Object> formData = new LinkedMultiValueMap<>();
+        formData.add("notify_id", notifyId);
+        formData.add("timestamp", String.valueOf(System.currentTimeMillis()));
+        formData.add("content", buildContent(order));
 
-                ResponseEntity<String> response = restTemplate.postForEntity(
-                        url, new HttpEntity<>(formData, headers), String.class);
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+        try {
+            ResponseEntity<String> response = restTemplate.postForEntity(
+                    url, new HttpEntity<>(formData, headers), String.class);
+            if (response.getStatusCode().is2xxSuccessful()) {
+                log.info("[汇安融回调] 成功: notify_id={}, url={}", notifyId, url);
+                return true;
+            }
+            log.warn("[汇安融回调] 返回非 2xx,记 FAILED 待调度器重试: notify_id={}, status={}, url={}",
+                    notifyId, response.getStatusCode(), url);
+            return false;
+        } catch (Exception e) {
+            log.warn("[汇安融回调] 推送异常,记 FAILED 待调度器重试: notify_id={}, url={}, error={}",
+                    notifyId, url, e.getMessage());
+            return false;
+        }
+    }
 
-                if (response.getStatusCode().is2xxSuccessful()) {
-                    log.info("[汇安融回调] 成功: notify_id={}, url={}", notifyId, url);
-                    return true;
-                }
-                // 4xx 是对方的问题,重试没有意义
-                if (response.getStatusCode().is4xxClientError()) {
-                    log.warn("[汇安融回调] 返回 {},不再重试: notify_id={}",
-                            response.getStatusCode(), notifyId);
-                    return false;
-                }
-                log.warn("[汇安融回调] 第 {} 次返回 {}", attempt + 1, response.getStatusCode());
+    /**
+     * 扫出推送失败且未达次数上限的订单再推一遍 —— 供轮询调度器周期调用。
+     *
+     * 对齐乐收赢 LsyCallbackService#retryFailed:同一查询形状(FAILED +
+     * count &lt; 上限 + 按 last_notify_time 升序饿最久的先推 + LIMIT 50)。
+     * 扫描异常只记不抛:调用方是调度线程,上面还挂着轮询主任务。
+     */
+    public void retryFailed() {
+        List<HarOrderEntity> orders;
+        try {
+            orders = orderMapper.selectList(new LambdaQueryWrapper<HarOrderEntity>()
+                    .eq(HarOrderEntity::getNotifyStatus, HarNotifyStatus.FAILED)
+                    .lt(HarOrderEntity::getNotifyCount, harProperties.getCallbackMaxRetries())
+                    .orderByAsc(HarOrderEntity::getLastNotifyTime)
+                    .last("LIMIT 50"));
+        } catch (Exception e) {
+            log.error("[汇安融回调] 扫描待重试订单失败: {}", e.getMessage(), e);
+            return;
+        }
+        for (HarOrderEntity order : orders) {
+            try {
+                notifyEnterprise(order);
             } catch (Exception e) {
-                log.warn("[汇安融回调] 第 {} 次失败: {}", attempt + 1, e.getMessage());
-            }
-            if (attempt < MAX_ATTEMPTS - 1) {
-                try {
-                    Thread.sleep(BACKOFF_SECONDS[attempt] * 1000L);
-                } catch (InterruptedException ie) {
-                    Thread.currentThread().interrupt();
-                    return false;
-                }
+                // notifyEnterprise 各段自身都有保护,这里是防将来改动引入的抛出拖垮整批
+                log.error("[汇安融回调] 重试推送异常: order_no={}, error={}",
+                        order.getOrderNo(), e.getMessage());
             }
         }
-        log.error("[汇安融回调] {} 次尝试后仍失败: url={}", MAX_ATTEMPTS, url);
-        return false;
     }
 }

+ 5 - 3
java/src/main/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkService.java

@@ -50,12 +50,14 @@ public class HarCheckoutLinkService {
 
     /**
      * 顾客页是 hash 路由({@code createWebHashHistory})⇒ 路径必须在 {@code #} 之后。
-     * 写成 {@code /checkout/har/{token}} 会命中静态路由表末尾的通配 404。
+     * 写成 {@code /checkout/alipay/{token}} 会命中静态路由表末尾的通配 404。
      *
-     * <p>★ {@code /har/} 这一段把本通道与乐收赢的 {@code /#/checkout/{token}} 分开:
+     * <p>★ {@code /alipay/} 这一段把本通道与乐收赢的 {@code /#/checkout/{token}} 分开:
      * 两条路由都是两段/三段的区别,落在前端两张页面上,互不遮蔽。
+     * (2026-10-09 由 {@code /har/} 改来 —— 这串会出现在顾客眼前,不带上游品牌;
+     * 前端 {@code router/index.ts} 的路径段必须与此处**同名**,改单边就是整页 404。)
      */
-    private static final String PATH_PREFIX = "/#/checkout/har/";
+    private static final String PATH_PREFIX = "/#/checkout/alipay/";
 
     /** 未知 / 停用**共用一句**:不告诉打开链接的人「这条链接存在但被停用」。 */
     private static final String MSG_INVALID = "收款链接无效或已停用";

+ 130 - 16
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -35,25 +35,30 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.math.BigDecimal;
+import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.OffsetDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * 汇安融订单服务。
  *
- * 订单号策略:order_no 由我方生成(HAR 前缀 + 雪花 ID),作为 third_order_no 传上游。
+ * 订单号策略:order_no 由我方生成(AP 前缀 + 雪花 ID),作为 third_order_no 传上游。
  * 企业自己的单号存 out_biz_no,不直接透传 —— 不同企业的单号可能重复,
  * 而上游的 third_order_no 需要全局唯一。
+ *
+ * <p>AP 前缀是 2026-10-09 从 HAR 改来的(去上游化):order_no 出现在开放 API 响应、
+ * 顾客收款页与上游的 third_order_no 里,前缀不带上游品牌。存量单不改。
  */
 @Slf4j
 @Service
 @RequiredArgsConstructor
 public class HarOrderService {
 
-    private static final String ORDER_NO_PREFIX = "HAR";
+    private static final String ORDER_NO_PREFIX = "AP";
 
     private static final DateTimeFormatter TIME_FORMATTER =
             DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -75,8 +80,35 @@ public class HarOrderService {
      */
     private static final String DEFAULT_REFUND_REASON = "商户申请退款";
 
+    /**
+     * {@code trade_type=alipay_un_stage}(支付宝支付,非分期)时上游要求的 {@code biz} 取值。
+     *
+     * <p>这串取自规范 260924 的订单创建参数表,**带上游品牌** —— 2026-10-09 起不再让
+     * 调用方填、也不用知道:请求体留空时由 {@link #resolveBiz} 派生,落库与发上游同一份
+     * (配套改动:{@link HarOrderVO} 不再回吐 biz,开放文档改为「留空即可」)。
+     */
+    private static final String BIZ_ALIPAY_UN_STAGE = "har_ali_un_stage";
+
+    /**
+     * 调用方没填 {@code trade_type} 时的默认值(非分期,支付宝支付)。
+     *
+     * <p>2026-10-09 起从调用方契约里拿掉:绝大多数接入只要非分期这一种,让每个调用方
+     * 照抄枚举值没有意义(配套:{@link HarOrderCreateDTO#getTradeType} 不再必填,
+     * 开放文档不再列它)。显式传值的仍原样透传 —— 分期与后台表单照旧。
+     */
+    private static final String TRADE_TYPE_ALIPAY_UN_STAGE = "alipay_un_stage";
+
     private final HarOrderMapper orderMapper;
     private final HarMerchantService merchantService;
+    /**
+     * 按地区挑店(开放 API 传顾客 IP / 省码时用,2026-10-09)。
+     *
+     * <p>【只在没传门店码时被问到】{@link #create} 里显式 {@code shop_code}/{@code third_shop_code}
+     * 优先;都不给而给了 {@code ip}/{@code province_code} 才调 {@link HarShopService#pickForOrder}
+     * (IP 优先、省码回退;挑不出来它抛 400,本类不吞)。收款链接腿走的是同一个服务的
+     * {@code pickForRegion}(那边地区来自顾客 IP)。
+     */
+    private final HarShopService shopService;
     private final HarClient harClient;
     /**
      * 订单信号发布(订单实时推送)。
@@ -90,6 +122,17 @@ public class HarOrderService {
      * 的回退 —— 清册以本文件里的发布调用点为准(按方法名找,别按行号)。
      */
     private final OrderEventPublisher publisher;
+    /**
+     * 商家回调(2026-10-09 补齐轮询腿)。
+     *
+     * <p>【触发纪律】只在**真的写了行、且商户可见状态真的变了**之后推:
+     * {@link #syncFromUpstream} 的查询成功尾(rows&gt;0 且状态/退款时间真流转)、
+     * {@link #doRefund} 的抢占、{@link #rollbackRefundPreempt} 的回退。
+     * {@link #create} 不推(调用方同步已拿到响应,后续流转由通知/轮询送);
+     * {@link #closeRejectedOrder} 不推(上游拒绝是下单腿的同步失败,调用方当场就知道)。
+     * 存量单不补推。
+     */
+    private final HarCallbackService callbackService;
 
     public PageResult<HarOrderVO> list(int pageNo, int pageSize, HarOrderQueryDTO q) {
         LambdaQueryWrapper<HarOrderEntity> w = new LambdaQueryWrapper<>();
@@ -271,6 +314,9 @@ public class HarOrderService {
         }
         // 抢占真的改了行(守卫命中)才发 —— 没命中的分支上面已经抛了,页面什么都没有变
         publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, order.getTenantId());
+        // 商家回调:受理(1 已支付 → 4 退款中)也是一次真实流转 —— 等上游回话期间,
+        // 商户侧就该是「退款中」(上游拒绝时下面的回退腿会再补一帧翻回去)。
+        callbackService.notifyEnterprise(order);
 
         HarRefundRequest req = new HarRefundRequest();
         // 优先用上游单号;拿不到时用我方单号走 third_order_no(同查询口径,
@@ -333,6 +379,8 @@ public class HarOrderService {
         // 回滚也要发:页面刚从「退款中」被点亮,现在要翻回「已支付」——
         // 不发这一帧,退款页/订单页会一直停在那个从没发生过的退款中。
         publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, order.getTenantId());
+        // 商家回调:先 4 后 1 两帧都要送 —— 缺了这一帧,商户会一直以为这笔在退款中。
+        callbackService.notifyEnterprise(order);
     }
 
     /**
@@ -406,6 +454,18 @@ public class HarOrderService {
             merchant = merchantService.requireByTenant(getCurrentTenantId());
         }
 
+        // 按顾客归属挑店(开放 API:调用方传顾客 IP / GB/T 2260 省码,2026-10-09)。
+        // 规则:显式门店码优先;都没给而给了 IP 或省码 ⇒ 平台替调用方挑
+        // (IP 优先、省码回退;挑不出来 pickForOrder 直接抛 400)。
+        // 必须先于落库与上游 —— 与收款链接腿同一口径:挑不出来就拒,本地不留单、上游不打。
+        String thirdShopCode = dto.getThirdShopCode();
+        if (StrUtil.isBlank(dto.getShopCode()) && StrUtil.isBlank(thirdShopCode)
+                && (StrUtil.isNotBlank(dto.getIp()) || StrUtil.isNotBlank(dto.getProvinceCode()))) {
+            thirdShopCode = shopService.pickForOrder(
+                    merchant.getMerchantCode(), dto.getProvinceCode(), dto.getIp(),
+                    dto.getOutBizNo()).getThirdShopId();
+        }
+
         String orderNo = ORDER_NO_PREFIX + SnowflakeIdGenerator.nextIdStr();
         boolean scanMode = StrUtil.isNotBlank(dto.getAuthCode());
 
@@ -419,11 +479,11 @@ public class HarOrderService {
         entity.setApiKey(apiKey);
         entity.setMerchantCode(merchant.getMerchantCode());
         entity.setShopCode(dto.getShopCode());
-        entity.setThirdShopCode(dto.getThirdShopCode());
+        entity.setThirdShopCode(thirdShopCode);
         entity.setPrice(dto.getPrice());
         entity.setTitle(dto.getTitle());
-        entity.setTradeType(dto.getTradeType());
-        entity.setBiz(dto.getBiz());
+        entity.setTradeType(resolveTradeType(dto));
+        entity.setBiz(resolveBiz(dto));
         entity.setStageNum(dto.getStageNum());
         entity.setPercent(dto.getPercent());
         entity.setPayTimeout(dto.getPayTimeout());
@@ -440,8 +500,8 @@ public class HarOrderService {
         // 本地单已落库后才发起上游调用:上游超时/失败时本地仍有完整记录,
         // 且 next_query_time 已排入轮询,不会因网络问题凭空丢单。
         HarResponse response = scanMode
-                ? harClient.pay(buildPayRequest(dto, merchant, orderNo))
-                : harClient.precreate(buildPrecreateRequest(dto, merchant, orderNo));
+                ? harClient.pay(buildPayRequest(dto, merchant, orderNo, thirdShopCode))
+                : harClient.precreate(buildPrecreateRequest(dto, merchant, orderNo, thirdShopCode));
 
         if (!response.isSuccess()) {
             // 上游**明确拒绝**(参数错误之类)⇒ 这笔单在上游从来没被建出来。
@@ -510,6 +570,10 @@ public class HarOrderService {
 
         order.setLastQueryTime(OffsetDateTime.now());
         order.setQueryCount(order.getQueryCount() == null ? 1 : order.getQueryCount() + 1);
+        // 流转快照:只有本次同步真的改变了「商户看得见的状态/退款时间」,才在写库成功后
+        // 触发商家回调(见尾部)。必须在 applyQueryDetail 之前抓 —— 它直接改 order。
+        String statusBefore = order.getTradeStatus();
+        OffsetDateTime refundTimeBefore = order.getRefundTime();
 
         HarResponse response = harClient.query(req);
         if (!response.isSuccess()) {
@@ -543,14 +607,38 @@ public class HarOrderService {
             order.setNextQueryTime(OffsetDateTime.now()
                     .plusSeconds(nextDelaySeconds(order.getQueryCount(), false)));
         }
-        orderMapper.updateById(order);
+        int rows = orderMapper.updateById(order);
         // 查询成功这条尾(状态/金额/上游单号都可能变了)才发。
         // **上面那条查询失败的分支不发** —— 它只推 next_query_time 一个游标,
         // 订单列表页看不到这一格,每轮轮询发一帧就是纯噪音。
         publisher.changed(OrderEventPublisher.CHANNEL_HAR_ORDER, order.getTenantId());
+        // 商家回调:这是上游通知腿之外的**第二条触发面** —— 通知窗口只有约 25 秒,
+        // 丢了的通知只能靠这条轮询腿收敛,收敛了就必须补一次回调。
+        // 两个闸门:① rows>0 —— updateById 被乐观锁挡掉(0 行)时内存里的状态没写进库,
+        // 不许对外宣称;② 真流转 —— 每轮轮询的 no-op 不许重复打扰商户。
+        if (rows > 0 && transitioned(statusBefore, refundTimeBefore, order)) {
+            callbackService.notifyEnterprise(order);
+        }
         return HarOrderVO.of(order);
     }
 
+    /**
+     * 状态字符串或退款时间**时刻**变了 = 一次商户可见的流转。
+     *
+     * 退款时间按 {@link Instant} 比较而不是 OffsetDateTime.equals:库读回的表示可能
+     * 带别的时区偏移(pgjdbc 读 timestamp 挂 UTC 的历史事故),同一时刻的不同表示
+     * 不算流转 —— 否则退款成功后每一轮轮询都会重发回调。
+     */
+    private static boolean transitioned(String statusBefore, OffsetDateTime refundBefore,
+                                        HarOrderEntity after) {
+        if (!Objects.equals(statusBefore, after.getTradeStatus())) {
+            return true;
+        }
+        Instant before = refundBefore == null ? null : refundBefore.toInstant();
+        Instant current = after.getRefundTime() == null ? null : after.getRefundTime().toInstant();
+        return !Objects.equals(before, current);
+    }
+
     /**
      * 把上游 detail 映射到订单实体。
      *
@@ -683,20 +771,45 @@ public class HarOrderService {
                 .atZone(ZoneId.systemDefault()).toOffsetDateTime();
     }
 
+    /**
+     * 解析 trade_type(2026-10-09):留空(null / 空串)⇒ {@link #TRADE_TYPE_ALIPAY_UN_STAGE};
+     * 显式取值原样透传、不校验 —— 分期({@code alipay_stage})与后台表单走这条路。
+     *
+     * <p>落库、两条上游请求、以及 {@link #resolveBiz} 的派生都读这个解析结果:
+     * 各读各的 {@code dto.getTradeType()} 的话,留空时落库为空、上游收到默认值,对不上账。
+     */
+    private static String resolveTradeType(HarOrderCreateDTO dto) {
+        return StrUtil.isBlank(dto.getTradeType()) ? TRADE_TYPE_ALIPAY_UN_STAGE : dto.getTradeType();
+    }
+
+    /**
+     * 解析 biz(2026-10-09 去上游化):调用方显式给了就原样透传(后台表单与既有调用方
+     * 仍按老口径传);留空时按 trade_type 派生 —— 非分期({@code alipay_un_stage},
+     * 含留空被填充的那一档)⇒ {@link #BIZ_ALIPAY_UN_STAGE};分期与其余取值没有规范依据,
+     * 保持空(不替上游编)。
+     */
+    private static String resolveBiz(HarOrderCreateDTO dto) {
+        if (StrUtil.isNotBlank(dto.getBiz())) {
+            return dto.getBiz();
+        }
+        return TRADE_TYPE_ALIPAY_UN_STAGE.equals(resolveTradeType(dto)) ? BIZ_ALIPAY_UN_STAGE : null;
+    }
+
     private HarPrecreateRequest buildPrecreateRequest(HarOrderCreateDTO dto,
                                                       HarMerchantEntity merchant,
-                                                      String orderNo) {
+                                                      String orderNo,
+                                                      String thirdShopCode) {
         HarPrecreateRequest req = new HarPrecreateRequest();
         req.setPrice(dto.getPrice().toPlainString());
         req.setTitle(dto.getTitle());
         req.setStageNum(dto.getStageNum());
-        req.setTradeType(dto.getTradeType());
-        req.setBiz(dto.getBiz());
+        req.setTradeType(resolveTradeType(dto));
+        req.setBiz(resolveBiz(dto));
         req.setPercent(dto.getPercent());
         req.setMerchantCode(merchant.getMerchantCode());
         req.setThirdOrderNo(orderNo);
         req.setShopCode(dto.getShopCode());
-        req.setThirdShopCode(dto.getThirdShopCode());
+        req.setThirdShopCode(thirdShopCode);
         req.setPayTimeout(dto.getPayTimeout());
         req.setExtraParams(dto.getExtraParams());
         req.setThirdNotifyUrl("https://qcsj88888.com/api/v1/payment/notify/har");
@@ -705,19 +818,20 @@ public class HarOrderService {
 
     private HarPayRequest buildPayRequest(HarOrderCreateDTO dto,
                                           HarMerchantEntity merchant,
-                                          String orderNo) {
+                                          String orderNo,
+                                          String thirdShopCode) {
         HarPayRequest req = new HarPayRequest();
         req.setPrice(dto.getPrice().toPlainString());
         req.setTitle(dto.getTitle());
         req.setAuthCode(dto.getAuthCode());
         req.setStageNum(dto.getStageNum());
-        req.setTradeType(dto.getTradeType());
-        req.setBiz(dto.getBiz());
+        req.setTradeType(resolveTradeType(dto));
+        req.setBiz(resolveBiz(dto));
         req.setPercent(dto.getPercent());
         req.setMerchantCode(merchant.getMerchantCode());
         req.setThirdOrderNo(orderNo);
         req.setShopCode(dto.getShopCode());
-        req.setThirdShopCode(dto.getThirdShopCode());
+        req.setThirdShopCode(thirdShopCode);
         req.setPayTimeout(dto.getPayTimeout());
         req.setExtraParams(dto.getExtraParams());
         req.setThirdNotifyUrl("https://qcsj88888.com/api/v1/payment/notify/har");

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

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

+ 6 - 5
java/src/main/java/com/payment/platform/module/payment/har/util/HarRegionCatalog.java

@@ -33,7 +33,8 @@ import java.util.Optional;
  * {@code source} 块记着同样的出处与 sha256:
  * <ul>
  *   <li>省市:{@code modood/Administrative-divisions-of-China} 的 {@code dist/provinces.json}
- *       + {@code dist/cities.json}(GB/T 2260 编码);</li>
+ *       + {@code dist/cities.json}(数据集里是短码:省两位、地级四位;生成时统一扩成
+ *       GB/T 2260 完整六位 —— 省 2 位 + 0000、地级 4 位 + 00,与汇安融对照表一致);</li>
  *   <li>繁简:{@code BYVoid/OpenCC} 的 {@code data/dictionary/TSCharacters.txt}(Apache-2.0),
  *       **只取其中指向本目录用字的繁体键**(166 条)。</li>
  * </ul>
@@ -44,8 +45,8 @@ import java.util.Optional;
  * <ol>
  *   <li><b>只含大陆 31 个省级</b>:港澳台不在内。ipwho.is 对它们的 {@code country}
  *       不是「中国」,解析器那一步就先判掉了,这里不需要它们;</li>
- *   <li><b>占位名按省名展示</b>:GB/T 2260 里直辖市的城市码是 1101/1201/3101/5001,
- *       名字是占位的「市辖区」(5002 是「县」)。生成脚本把它们换成省名
+ *   <li><b>占位名按省名展示</b>:GB/T 2260 里直辖市的城市码是 110100/120100/310100/500100,
+ *       名字是占位的「市辖区」(500200 是「县」)。生成脚本把它们换成省名
  *       ({@code 北京市} / {@code 重庆市县})—— 既让下拉能读,也让「北京市 + 北京」
  *       这种回包能命中**市**一级而不必回退到全省;</li>
  *   <li><b>繁简表的键不得是目录自己的字</b>:{@code 沈} 是 {@code 沉} 的繁体变体,
@@ -81,11 +82,11 @@ public class HarRegionCatalog {
     /** 族名:{@code 广西壮族自治区} / {@code 昌吉回族自治州} 去掉它才能与简称对上 */
     private static final List<String> ETHNONYMS = List.of("壮族", "回族", "维吾尔");
 
-    /** 省级。{@code code} 是 GB/T 2260 两位省码。 */
+    /** 省级。{@code code} 是 GB/T 2260 六位省码({@code 320000} = 江苏省)。 */
     public record Province(String code, String name) {
     }
 
-    /** 地级。{@code code} 四位;{@code name} 已是**展示名**(见类 javadoc 第 2 条)。 */
+    /** 地级。{@code code} 是 GB/T 2260 六位市码({@code 320100} = 南京市);{@code name} 已是**展示名**(见类 javadoc 第 2 条)。 */
     public record City(String code, String name, String provinceCode) {
     }
 

+ 3 - 3
java/src/main/resources/application.yml

@@ -124,16 +124,16 @@ har:
   # 上游根地址。**不含 context-path** —— 代码固定拼 /kf-alipay-api,
   # 由上游给的完整 URL 剥掉这一段填入。拼出的形态与 demo Main.java:13 一致:
   #   {base-url}/kf-alipay-api/api/alipay/trade/query
-  base-url: https://har1.huihua365.com
+  base-url: https://kf.huihua365.com
   # 上游分配的应用编号(每个请求都原文发给上游,非机密)
-  app-id: ${HAR_APP_ID:202609241054000002}
+  app-id: ${HAR_APP_ID:2026100917135500001}
   version: "2.0"
   # 我方私钥,PKCS#8 纯 Base64(URL-safe 与标准字母表都接受)。
   # ★ 全仓唯一一件真凭据:绝不写进本文件,只由环境变量注入,默认值必须留空。
   our-private-key: ${HAR_OUR_PRIVATE_KEY:MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDMvHN5gPKmD3fzOB6lq0WhXzwt5QyP3618WcMesAW4nINTzztt8j1NrIYtCu29FchQXPu28Tg6vwiAu0kc1XtJjGB11gFadEs0n03PW06pFevq6Oi4JX1jwLBsPEzWwshEfcz3S4DughTC6HK1rQyv/QFymO6exZSAGi5JdZ62na6Z/5lcqIPPdER2wYklru1qycYVVwogkRQtLtZBTLVrBS3W0ojLkBwXw6pDQGWDLxnhO65CfjWO8H4+PGb0GsFJhr3TuUVS3GRFhoW3cC1qjj95Ry/QnS/bDHA9F6ypoAEs2CEBbuWpo9GxsYIsnXMHFmzgouhohgANo2DCJxLdAgMBAAECggEAH25eBv0II3zT9Cu9Lr9myvGpNj2ICm1zP5gzN5qb4j3q/nqd29D43twZ5D/dhC4cKNRBk5IgSQqLZmH6RToz+XRa2bRPVGDuq8c6JZeVsakrJQ75LU4aHC6YlyFBxaW1I6ixhxSZGGSxhcO0WpZU0I7Vwk9esNLR68G5Me9j/JJdosYKv9bWOspJ7KMf39uDfC2ONXN+pGIkRCKKsAX+4G236QAl5m0xe2pyzuhge+94ASAQ2aXCXpZLnFv11KDxpTbVFj76bYHd8bfadHF6dd0dtQSIn9hqC4hm3IeuW57PQUx/CSokYOe1g8NsP/qmC9pTufL6yXixuk1qsZVHsQKBgQD15NYQv326tpFOu1LegYLOSo6YSsqLofANaIaAJDe+dqhf9imdr/X489R3NHJhj/LpeojtrPVd0IAT8lcvQKZcdXyNOEuOTjx3Tum9Jnbo4WI0I1Ev6ZjSXZIvE6j3nCG8cJ8ULETEExx9qr3Ejj9Wp/kkx3b7lFcQCpkXSHcZUQKBgQDVJpM1gO2kOBMDF5veKOk2vVQmGb0SLPVyQ0DkLevX1PfnNUD5pbKnsFzIHqU37lNZ5E7SLF7+OCjmPmU8Kge4pMchaJvL1O2yagaiExCj//zp3fDrfi+npGINsKeB/2l4mBKygHgcLg8ejpVxvyRsAv8+J8Vy+mAg9cyXZee9zQKBgDKzWXQgl8MM688pDsA0Qg1ZECfUnAjIfh+1oq3/ScMq4jLPJeuOoZAQbomZtc/qB3MewecH15vdZts2B8YFdYfw7ZqMtf+EoDEVg1pt+OyxtDb95TqU1t7hTkgIrLlJZydcrWBSdGddbW137CMAFY0/mz57JkxfXMhA9yn21h/RAoGANmOwfp+WhCf22fwlPswrrlcjaNF6iG/GddCpd1AsdReX5nyVRt68NsPu+PWw7RO4WyDB6CmAV9hWv2G+jVqgO8J4OZMq5hlASh158dNxlJeWnsqOVlkNqfZtOtcP90EIVchgRQI5I9+w5fg2NB81GQauwNp/LLas3tw5x4wuVSUCgYAgiRTniAN1ZQD5nM6xcgg2MO67nWExo/HS1ftmyoKEMCnOML3fha7PkuTbUry10GxsSgJuXLnbDreIUteiA6bgJECt3jZ5IHtwgMhfnTSCy6oL548sZmlhF7J9liVfKSkfnJ6saMWqCjnxCHRtgW76mKmFWD+SZbkFPV/D7+HiWg==}
   # 汇安融公钥,X.509 纯 Base64(通知验签用,必须配)。
   # 公钥按定义就是可公开的,故给出联调默认值;换环境用环境变量覆盖。
-  platform-public-key: ${HAR_PLATFORM_PUBLIC_KEY:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkMKjpUOq_yY28INUtEeZbYHPZ57BXwlmroWTDi-jFpCwC1X5WFOck9GKW3jDRwsEBLS1zaICrIqSn9U91YjWWg3wJYAD75nXrfMRGpfHaF-kTah3-2i2IwWEpbEshLzYIAYGQBxo0xMxys71B-rkwh4iiE9THg0IrpI5bthVUgE3-JyzKQz2TL3lw9vFGPiLRprHwta6-BTsuxW5gNJq2G63MNlcTOGHMcDg5ma4OLSBMc9J0iEaRcDC6CK1PcjDHxI9SlqKub2Aun08rOS2d21QKENCw7axwbwlAnE5s7HCWzaPGduLaRu-nEM7HnGMWMJQt_1_sCb5IBc8bhamywIDAQAB}
+  platform-public-key: ${HAR_PLATFORM_PUBLIC_KEY:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiyXVIro20ZejxI5MwRqheyXqdIDWbH8_WjO90gQkpXtZJnIvNpxt5jE89e2fWHdnMq-SpZYWsS2xbbd7B5QqALjq3-_WUHAbWqBSYdide37hlmG0oXrfUV1_RgK43pEtrcgVPBLIwGFmW7qTjc3l59_amYVgu9kOi8F-acpP9UZ_BZT_sdFwawQFCwq8Tl0D54zjRjsNLdosNL2whctdby3b9nCaBmG7FRW25k_lo3GG3ETos70IxA6z1GGFBmYhOSz4j6EE3ICFqBjp9PFOpihAXC4ZZSeDygOAtW08UgLAiP0SMmi4TQB5wn554W5Kk0VZChg0wBywerVSXkwemwIDAQAB}
   callback-pool-size: 4
   connect-timeout-ms: 5000
   read-timeout-ms: 15000

+ 66 - 0
java/src/main/resources/db/har-callback-retry.sql

@@ -0,0 +1,66 @@
+-- pay_har_order 新增回调重试两列 + 重试扫描部分索引(2026-10-09)
+-- 手工执行: psycopg2 / psql 连接 payment_platform_java 库
+--
+-- 【本脚本尚未执行】执行由用户做。
+--
+-- ── 背景 ─────────────────────────────────────────────────────────────
+-- 本次交付补齐「下游回调失败自动重试」(对齐乐收赢同款):
+--   HarCallbackService#writeNotifyResult 每次回调尝试都写 notify_count+1 / last_notify_time;
+--   HarOrderPollScheduler#retryFailedCallbacks 每 60 秒扫一遍
+--       notify_status='FAILED' 且 notify_count < har.callback-max-retries(默认 8)
+--       按 last_notify_time 升序(饿最久的先推)、单轮 LIMIT 50,重新推送。
+-- 这两列此前没有(pay_har_order 建于 V1.8__create_pay_har_tables.sql,当时只有
+-- notify_status),而实体 HarOrderEntity 已映射 notifyCount / lastNotifyTime。
+--
+-- ── ⚠ 顺序:先执行本脚本,再换新 jar ──────────────────────────────────
+-- 新 jar 下每次回调尝试都要 UPDATE 这两列。列不存在 ⇒ 该 UPDATE 抛错、被
+-- writeNotifyResult 的 catch 吞成一行 warn —— 表现是「回调推了但状态永远停在
+-- PENDING」,且这段窗口事后补跑脚本也救不回来(PENDING 不在重试扫描的捞取范围内)。
+-- 同时 retryFailed 的 SELECT 每分钟抛一次错(只记日志,不掀调度线程)。
+--
+-- ── ⚠ 存量不补推(用户 2026-10-09 裁定)──────────────────────────────
+-- ALTER 给存量行的 notify_count 填默认 0,而扫描判据是 count < 上限 ⇒
+-- 执行那一刻已有的 FAILED 单会被当成「没试满」全部捞出来补推,每分钟最多 50 条。
+-- 这不是本次要的行为(要补推的是**部署之后**才失败的推送),所以下面把
+-- 执行那一刻的存量 FAILED 行一次性封顶到上限,让它们不进重试扫描。
+-- 新产生的失败(含存量单在部署后因状态收敛新触发的推送失败)从 1 开始正常重试。
+-- ★ 封顶值取默认上限 8。若生产把 har.callback-max-retries 配得比 8 **大**,
+--   把下面 UPDATE 与被封顶行的值改成那个数(配得比 8 小不用改,8 同样够不到)。
+--
+-- ── 执行 ─────────────────────────────────────────────────────────────
+ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS notify_count int NOT NULL DEFAULT 0;
+ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS last_notify_time timestamp;
+
+COMMENT ON COLUMN pay_har_order.notify_count IS
+    '下游回调已尝试次数(含首次与「缺地址」那次)—— 重试扫描的封顶计数';
+COMMENT ON COLUMN pay_har_order.last_notify_time IS
+    '最近一次回调尝试时间 —— 重试扫描的排序键(饿最久的先推)';
+
+-- 存量 FAILED 行封顶(只动 count=0 的:新 jar 写出的 FAILED 行 count 必然 ≥1,
+-- 所以本句重跑也不会把正常重试中的单踢出扫描)
+UPDATE pay_har_order SET notify_count = 8 WHERE notify_status = 'FAILED' AND notify_count = 0;
+
+-- 重试扫描的唯一热路径是 notify_status='FAILED' AND notify_count < 上限
+-- ORDER BY last_notify_time LIMIT 50。FAILED 只是全部订单里极小的一部分,
+-- 用部分索引只覆盖这部分行(谓词 + 排序键一个索引全包)。
+CREATE INDEX IF NOT EXISTS idx_har_order_callback_retry
+    ON pay_har_order (last_notify_time) WHERE notify_status = 'FAILED';
+
+-- ── 只读核对(执行后跑)──────────────────────────────────────────────
+-- ① 列在不在、形状对不对
+-- SELECT column_name, data_type, is_nullable, column_default
+--   FROM information_schema.columns
+--  WHERE table_name = 'pay_har_order' AND column_name IN ('notify_count','last_notify_time');
+-- 期望:last_notify_time | timestamp without time zone | YES | (空)
+--       notify_count     | integer                   | NO  | 0
+--
+-- ② 索引在不在
+-- SELECT indexname FROM pg_indexes
+--  WHERE tablename = 'pay_har_order' AND indexname = 'idx_har_order_callback_retry';
+-- 期望:一行
+--
+-- ③ ★ 存量是否已全部封顶(这条同时量出了「原本会被补推的存量单数」)
+-- SELECT count(*) FILTER (WHERE notify_count < 8) AS 还会进扫描的存量_FAILED,
+--        count(*)                                AS 存量_FAILED_总数
+--   FROM pay_har_order WHERE notify_status = 'FAILED';
+-- 期望:前者 0(后者是存量规模,记下来即可)

+ 1 - 1
java/src/main/resources/db/har-checkout-link.sql

@@ -38,7 +38,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_har_checkout_token
 CREATE UNIQUE INDEX IF NOT EXISTS uk_har_checkout_tenant_mch
     ON pay_har_checkout_link (tenant_id, merchant_code);
 
-COMMENT ON TABLE pay_har_checkout_link IS '汇安融收款链接(一商户一条;打开 /#/checkout/har/{token} 输金额后当场建主扫单)';
+COMMENT ON TABLE pay_har_checkout_link IS '汇安融收款链接(一商户一条;打开 /#/checkout/alipay/{token} 输金额后当场建主扫单)';
 COMMENT ON COLUMN pay_har_checkout_link.id            IS '主键ID(MyBatis-Plus ASSIGN_ID 雪花)';
 COMMENT ON COLUMN pay_har_checkout_link.tenant_id     IS '租户ID(发放时取自商户行,不来自请求体)';
 COMMENT ON COLUMN pay_har_checkout_link.merchant_code IS '汇安融商户编号(钱进哪个商户由它决定;下单时回 pay_har_merchant 复核一遍)';

+ 1 - 1
java/src/main/resources/db/har-order-refund-menu.sql

@@ -11,7 +11,7 @@
 --
 -- 谁拿到这个权限:
 --   role 1 = ADMIN、role 2 = tenant 都授予 —— 与现状一致,租户本来就能通过
---   开放 API(POST /payment/openapi/har/refund)退自己商户的单,后台按钮只是同一能力的界面。
+--   开放 API(POST /payment/openapi/alipay/refund)退自己商户的单,后台按钮只是同一能力的界面。
 --   若要**只给 ADMIN、不给租户**:执行完把最后那段 role 2 的 INSERT 换成它下面注释里的 DELETE。
 
 -- ---------- 权限点:挂在「订单管理」页面下 ----------

+ 19 - 2
java/src/main/resources/db/har-shop-region.sql

@@ -1,4 +1,6 @@
 -- pay_har_shop 新增 province_code / city_code(2026-10-08,规范 261008 5.5 + 异地收款风控)
+-- 2026-10-09 订正:编码统一为 GB/T 2260 完整六位(省 2 位+0000、地级 4 位+00,与汇安融
+-- 对照表一致);用旧短码建的门店见本文件「短码订正」段一次性 UPDATE 过来。
 -- 手工执行: psycopg2 / psql 连接 payment_platform_java 库
 --
 -- 【本脚本尚未执行】执行由用户做。
@@ -31,9 +33,19 @@ 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 之前建的门店为空,且无法回填';
+    'GB/T 2260 六位省码(320000=江苏省)。发上游 province_code(必填),也是按顾客 IP 挑店的匹配键。2026-10-08 之前建的门店为空,且无法回填';
 COMMENT ON COLUMN pay_har_shop.city_code IS
-    'GB/T 2260 四位市码(3201=南京市)。发上游 city_code(必填),见 province_code';
+    'GB/T 2260 六位市码(320100=南京市)。发上游 city_code(必填),见 province_code';
+
+-- ── 短码订正(2026-10-09)──────────────────────────────────────────────
+-- 2026-10-08 到 10-09 之间用旧代码建出的门店,这两列存的是数据集短码(32 / 3201)。
+-- 目录换成六位后它们会**静默落选**(挑店按字符串精确比对),所以在这里补零订正。
+-- 幂等:已经六位的行(length >= 6)与 NULL 行(老店,见上面「存量补不上」)都不动,
+-- 重跑本脚本不会改坏任何一行。HAR 未上线,这段实际只影响测试环境已建的店。
+UPDATE pay_har_shop SET province_code = rpad(province_code, 6, '0')
+ WHERE province_code IS NOT NULL AND length(province_code) < 6;
+UPDATE pay_har_shop SET city_code = rpad(city_code, 6, '0')
+ WHERE city_code IS NOT NULL AND length(city_code) < 6;
 
 -- 挑店那句查询按 merchant_code 过滤(租户谓词由拦截器另加)—— 它在收款链接的
 -- **每一笔支付**上执行,给个索引免得随门店铺开变成顺扫。
@@ -54,3 +66,8 @@ CREATE INDEX IF NOT EXISTS idx_har_shop_merchant_code ON pay_har_shop (merchant_
 -- ③ ★ 有多少存量门店会因缺地区而落选(记下这个数字,它就是要重开的店数)
 -- SELECT count(*) AS 缺地区的店 FROM pay_har_shop
 --  WHERE province_code IS NULL OR city_code IS NULL;
+--
+-- ④ ★ 「短码订正」段跑完不该再有短码(期望 0 —— 非 0 就是 UPDATE 没跑到)
+-- SELECT count(*) AS 剩余短码 FROM pay_har_shop
+--  WHERE (province_code IS NOT NULL AND length(province_code) < 6)
+--     OR (city_code IS NOT NULL AND length(city_code) < 6);

+ 717 - 716
java/src/main/resources/har-region-cn.json

@@ -8,1844 +8,1845 @@
   "t2s": "BYVoid/OpenCC data/dictionary/TSCharacters.txt (Apache-2.0),取其中指向数据集用字的繁体键",
   "t2s_sha256": "9ff46a7d30e5765375eb13d33f2b03a34d298913caf2b120380679f33ae1642d",
   "generated_by": "logs/_har-region-gen.py(不进版本库)",
-  "labels": "GB/T 2260 占位名 市辖区→省名、县→省名+县"
+  "labels": "GB/T 2260 占位名 市辖区→省名、县→省名+县",
+  "codes": "GB/T 2260 完整六位:省 2 位+0000、地级 4 位+00(2026-10-09 按汇安融对照表截图核对;省直辖县级行政区划按同规则推)"
  },
  "provinces": [
   {
-   "code": "11",
+   "code": "110000",
    "name": "北京市"
   },
   {
-   "code": "12",
+   "code": "120000",
    "name": "天津市"
   },
   {
-   "code": "13",
+   "code": "130000",
    "name": "河北省"
   },
   {
-   "code": "14",
+   "code": "140000",
    "name": "山西省"
   },
   {
-   "code": "15",
+   "code": "150000",
    "name": "内蒙古自治区"
   },
   {
-   "code": "21",
+   "code": "210000",
    "name": "辽宁省"
   },
   {
-   "code": "22",
+   "code": "220000",
    "name": "吉林省"
   },
   {
-   "code": "23",
+   "code": "230000",
    "name": "黑龙江省"
   },
   {
-   "code": "31",
+   "code": "310000",
    "name": "上海市"
   },
   {
-   "code": "32",
+   "code": "320000",
    "name": "江苏省"
   },
   {
-   "code": "33",
+   "code": "330000",
    "name": "浙江省"
   },
   {
-   "code": "34",
+   "code": "340000",
    "name": "安徽省"
   },
   {
-   "code": "35",
+   "code": "350000",
    "name": "福建省"
   },
   {
-   "code": "36",
+   "code": "360000",
    "name": "江西省"
   },
   {
-   "code": "37",
+   "code": "370000",
    "name": "山东省"
   },
   {
-   "code": "41",
+   "code": "410000",
    "name": "河南省"
   },
   {
-   "code": "42",
+   "code": "420000",
    "name": "湖北省"
   },
   {
-   "code": "43",
+   "code": "430000",
    "name": "湖南省"
   },
   {
-   "code": "44",
+   "code": "440000",
    "name": "广东省"
   },
   {
-   "code": "45",
+   "code": "450000",
    "name": "广西壮族自治区"
   },
   {
-   "code": "46",
+   "code": "460000",
    "name": "海南省"
   },
   {
-   "code": "50",
+   "code": "500000",
    "name": "重庆市"
   },
   {
-   "code": "51",
+   "code": "510000",
    "name": "四川省"
   },
   {
-   "code": "52",
+   "code": "520000",
    "name": "贵州省"
   },
   {
-   "code": "53",
+   "code": "530000",
    "name": "云南省"
   },
   {
-   "code": "54",
+   "code": "540000",
    "name": "西藏自治区"
   },
   {
-   "code": "61",
+   "code": "610000",
    "name": "陕西省"
   },
   {
-   "code": "62",
+   "code": "620000",
    "name": "甘肃省"
   },
   {
-   "code": "63",
+   "code": "630000",
    "name": "青海省"
   },
   {
-   "code": "64",
+   "code": "640000",
    "name": "宁夏回族自治区"
   },
   {
-   "code": "65",
+   "code": "650000",
    "name": "新疆维吾尔自治区"
   }
  ],
  "cities": [
   {
-   "code": "1101",
+   "code": "110100",
    "name": "北京市",
-   "provinceCode": "11"
+   "provinceCode": "110000"
   },
   {
-   "code": "1201",
+   "code": "120100",
    "name": "天津市",
-   "provinceCode": "12"
+   "provinceCode": "120000"
   },
   {
-   "code": "1301",
+   "code": "130100",
    "name": "石家庄市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1302",
+   "code": "130200",
    "name": "唐山市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1303",
+   "code": "130300",
    "name": "秦皇岛市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1304",
+   "code": "130400",
    "name": "邯郸市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1305",
+   "code": "130500",
    "name": "邢台市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1306",
+   "code": "130600",
    "name": "保定市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1307",
+   "code": "130700",
    "name": "张家口市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1308",
+   "code": "130800",
    "name": "承德市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1309",
+   "code": "130900",
    "name": "沧州市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1310",
+   "code": "131000",
    "name": "廊坊市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1311",
+   "code": "131100",
    "name": "衡水市",
-   "provinceCode": "13"
+   "provinceCode": "130000"
   },
   {
-   "code": "1401",
+   "code": "140100",
    "name": "太原市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1402",
+   "code": "140200",
    "name": "大同市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1403",
+   "code": "140300",
    "name": "阳泉市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1404",
+   "code": "140400",
    "name": "长治市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1405",
+   "code": "140500",
    "name": "晋城市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1406",
+   "code": "140600",
    "name": "朔州市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1407",
+   "code": "140700",
    "name": "晋中市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1408",
+   "code": "140800",
    "name": "运城市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1409",
+   "code": "140900",
    "name": "忻州市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1410",
+   "code": "141000",
    "name": "临汾市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1411",
+   "code": "141100",
    "name": "吕梁市",
-   "provinceCode": "14"
+   "provinceCode": "140000"
   },
   {
-   "code": "1501",
+   "code": "150100",
    "name": "呼和浩特市",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1502",
+   "code": "150200",
    "name": "包头市",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1503",
+   "code": "150300",
    "name": "乌海市",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1504",
+   "code": "150400",
    "name": "赤峰市",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1505",
+   "code": "150500",
    "name": "通辽市",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1506",
+   "code": "150600",
    "name": "鄂尔多斯市",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1507",
+   "code": "150700",
    "name": "呼伦贝尔市",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1508",
+   "code": "150800",
    "name": "巴彦淖尔市",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1509",
+   "code": "150900",
    "name": "乌兰察布市",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1522",
+   "code": "152200",
    "name": "兴安盟",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1525",
+   "code": "152500",
    "name": "锡林郭勒盟",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "1529",
+   "code": "152900",
    "name": "阿拉善盟",
-   "provinceCode": "15"
+   "provinceCode": "150000"
   },
   {
-   "code": "2101",
+   "code": "210100",
    "name": "沈阳市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2102",
+   "code": "210200",
    "name": "大连市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2103",
+   "code": "210300",
    "name": "鞍山市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2104",
+   "code": "210400",
    "name": "抚顺市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2105",
+   "code": "210500",
    "name": "本溪市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2106",
+   "code": "210600",
    "name": "丹东市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2107",
+   "code": "210700",
    "name": "锦州市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2108",
+   "code": "210800",
    "name": "营口市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2109",
+   "code": "210900",
    "name": "阜新市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2110",
+   "code": "211000",
    "name": "辽阳市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2111",
+   "code": "211100",
    "name": "盘锦市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2112",
+   "code": "211200",
    "name": "铁岭市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2113",
+   "code": "211300",
    "name": "朝阳市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2114",
+   "code": "211400",
    "name": "葫芦岛市",
-   "provinceCode": "21"
+   "provinceCode": "210000"
   },
   {
-   "code": "2201",
+   "code": "220100",
    "name": "长春市",
-   "provinceCode": "22"
+   "provinceCode": "220000"
   },
   {
-   "code": "2202",
+   "code": "220200",
    "name": "吉林市",
-   "provinceCode": "22"
+   "provinceCode": "220000"
   },
   {
-   "code": "2203",
+   "code": "220300",
    "name": "四平市",
-   "provinceCode": "22"
+   "provinceCode": "220000"
   },
   {
-   "code": "2204",
+   "code": "220400",
    "name": "辽源市",
-   "provinceCode": "22"
+   "provinceCode": "220000"
   },
   {
-   "code": "2205",
+   "code": "220500",
    "name": "通化市",
-   "provinceCode": "22"
+   "provinceCode": "220000"
   },
   {
-   "code": "2206",
+   "code": "220600",
    "name": "白山市",
-   "provinceCode": "22"
+   "provinceCode": "220000"
   },
   {
-   "code": "2207",
+   "code": "220700",
    "name": "松原市",
-   "provinceCode": "22"
+   "provinceCode": "220000"
   },
   {
-   "code": "2208",
+   "code": "220800",
    "name": "白城市",
-   "provinceCode": "22"
+   "provinceCode": "220000"
   },
   {
-   "code": "2224",
+   "code": "222400",
    "name": "延边朝鲜族自治州",
-   "provinceCode": "22"
+   "provinceCode": "220000"
   },
   {
-   "code": "2301",
+   "code": "230100",
    "name": "哈尔滨市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2302",
+   "code": "230200",
    "name": "齐齐哈尔市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2303",
+   "code": "230300",
    "name": "鸡西市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2304",
+   "code": "230400",
    "name": "鹤岗市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2305",
+   "code": "230500",
    "name": "双鸭山市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2306",
+   "code": "230600",
    "name": "大庆市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2307",
+   "code": "230700",
    "name": "伊春市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2308",
+   "code": "230800",
    "name": "佳木斯市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2309",
+   "code": "230900",
    "name": "七台河市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2310",
+   "code": "231000",
    "name": "牡丹江市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2311",
+   "code": "231100",
    "name": "黑河市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2312",
+   "code": "231200",
    "name": "绥化市",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "2327",
+   "code": "232700",
    "name": "大兴安岭地区",
-   "provinceCode": "23"
+   "provinceCode": "230000"
   },
   {
-   "code": "3101",
+   "code": "310100",
    "name": "上海市",
-   "provinceCode": "31"
+   "provinceCode": "310000"
   },
   {
-   "code": "3201",
+   "code": "320100",
    "name": "南京市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3202",
+   "code": "320200",
    "name": "无锡市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3203",
+   "code": "320300",
    "name": "徐州市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3204",
+   "code": "320400",
    "name": "常州市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3205",
+   "code": "320500",
    "name": "苏州市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3206",
+   "code": "320600",
    "name": "南通市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3207",
+   "code": "320700",
    "name": "连云港市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3208",
+   "code": "320800",
    "name": "淮安市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3209",
+   "code": "320900",
    "name": "盐城市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3210",
+   "code": "321000",
    "name": "扬州市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3211",
+   "code": "321100",
    "name": "镇江市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3212",
+   "code": "321200",
    "name": "泰州市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3213",
+   "code": "321300",
    "name": "宿迁市",
-   "provinceCode": "32"
+   "provinceCode": "320000"
   },
   {
-   "code": "3301",
+   "code": "330100",
    "name": "杭州市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3302",
+   "code": "330200",
    "name": "宁波市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3303",
+   "code": "330300",
    "name": "温州市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3304",
+   "code": "330400",
    "name": "嘉兴市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3305",
+   "code": "330500",
    "name": "湖州市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3306",
+   "code": "330600",
    "name": "绍兴市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3307",
+   "code": "330700",
    "name": "金华市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3308",
+   "code": "330800",
    "name": "衢州市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3309",
+   "code": "330900",
    "name": "舟山市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3310",
+   "code": "331000",
    "name": "台州市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3311",
+   "code": "331100",
    "name": "丽水市",
-   "provinceCode": "33"
+   "provinceCode": "330000"
   },
   {
-   "code": "3401",
+   "code": "340100",
    "name": "合肥市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3402",
+   "code": "340200",
    "name": "芜湖市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3403",
+   "code": "340300",
    "name": "蚌埠市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3404",
+   "code": "340400",
    "name": "淮南市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3405",
+   "code": "340500",
    "name": "马鞍山市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3406",
+   "code": "340600",
    "name": "淮北市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3407",
+   "code": "340700",
    "name": "铜陵市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3408",
+   "code": "340800",
    "name": "安庆市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3410",
+   "code": "341000",
    "name": "黄山市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3411",
+   "code": "341100",
    "name": "滁州市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3412",
+   "code": "341200",
    "name": "阜阳市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3413",
+   "code": "341300",
    "name": "宿州市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3415",
+   "code": "341500",
    "name": "六安市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3416",
+   "code": "341600",
    "name": "亳州市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3417",
+   "code": "341700",
    "name": "池州市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3418",
+   "code": "341800",
    "name": "宣城市",
-   "provinceCode": "34"
+   "provinceCode": "340000"
   },
   {
-   "code": "3501",
+   "code": "350100",
    "name": "福州市",
-   "provinceCode": "35"
+   "provinceCode": "350000"
   },
   {
-   "code": "3502",
+   "code": "350200",
    "name": "厦门市",
-   "provinceCode": "35"
+   "provinceCode": "350000"
   },
   {
-   "code": "3503",
+   "code": "350300",
    "name": "莆田市",
-   "provinceCode": "35"
+   "provinceCode": "350000"
   },
   {
-   "code": "3504",
+   "code": "350400",
    "name": "三明市",
-   "provinceCode": "35"
+   "provinceCode": "350000"
   },
   {
-   "code": "3505",
+   "code": "350500",
    "name": "泉州市",
-   "provinceCode": "35"
+   "provinceCode": "350000"
   },
   {
-   "code": "3506",
+   "code": "350600",
    "name": "漳州市",
-   "provinceCode": "35"
+   "provinceCode": "350000"
   },
   {
-   "code": "3507",
+   "code": "350700",
    "name": "南平市",
-   "provinceCode": "35"
+   "provinceCode": "350000"
   },
   {
-   "code": "3508",
+   "code": "350800",
    "name": "龙岩市",
-   "provinceCode": "35"
+   "provinceCode": "350000"
   },
   {
-   "code": "3509",
+   "code": "350900",
    "name": "宁德市",
-   "provinceCode": "35"
+   "provinceCode": "350000"
   },
   {
-   "code": "3601",
+   "code": "360100",
    "name": "南昌市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3602",
+   "code": "360200",
    "name": "景德镇市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3603",
+   "code": "360300",
    "name": "萍乡市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3604",
+   "code": "360400",
    "name": "九江市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3605",
+   "code": "360500",
    "name": "新余市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3606",
+   "code": "360600",
    "name": "鹰潭市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3607",
+   "code": "360700",
    "name": "赣州市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3608",
+   "code": "360800",
    "name": "吉安市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3609",
+   "code": "360900",
    "name": "宜春市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3610",
+   "code": "361000",
    "name": "抚州市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3611",
+   "code": "361100",
    "name": "上饶市",
-   "provinceCode": "36"
+   "provinceCode": "360000"
   },
   {
-   "code": "3701",
+   "code": "370100",
    "name": "济南市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3702",
+   "code": "370200",
    "name": "青岛市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3703",
+   "code": "370300",
    "name": "淄博市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3704",
+   "code": "370400",
    "name": "枣庄市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3705",
+   "code": "370500",
    "name": "东营市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3706",
+   "code": "370600",
    "name": "烟台市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3707",
+   "code": "370700",
    "name": "潍坊市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3708",
+   "code": "370800",
    "name": "济宁市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3709",
+   "code": "370900",
    "name": "泰安市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3710",
+   "code": "371000",
    "name": "威海市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3711",
+   "code": "371100",
    "name": "日照市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3713",
+   "code": "371300",
    "name": "临沂市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3714",
+   "code": "371400",
    "name": "德州市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3715",
+   "code": "371500",
    "name": "聊城市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3716",
+   "code": "371600",
    "name": "滨州市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "3717",
+   "code": "371700",
    "name": "菏泽市",
-   "provinceCode": "37"
+   "provinceCode": "370000"
   },
   {
-   "code": "4101",
+   "code": "410100",
    "name": "郑州市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4102",
+   "code": "410200",
    "name": "开封市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4103",
+   "code": "410300",
    "name": "洛阳市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4104",
+   "code": "410400",
    "name": "平顶山市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4105",
+   "code": "410500",
    "name": "安阳市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4106",
+   "code": "410600",
    "name": "鹤壁市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4107",
+   "code": "410700",
    "name": "新乡市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4108",
+   "code": "410800",
    "name": "焦作市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4109",
+   "code": "410900",
    "name": "濮阳市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4110",
+   "code": "411000",
    "name": "许昌市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4111",
+   "code": "411100",
    "name": "漯河市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4112",
+   "code": "411200",
    "name": "三门峡市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4113",
+   "code": "411300",
    "name": "南阳市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4114",
+   "code": "411400",
    "name": "商丘市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4115",
+   "code": "411500",
    "name": "信阳市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4116",
+   "code": "411600",
    "name": "周口市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4117",
+   "code": "411700",
    "name": "驻马店市",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4190",
+   "code": "419000",
    "name": "省直辖县级行政区划",
-   "provinceCode": "41"
+   "provinceCode": "410000"
   },
   {
-   "code": "4201",
+   "code": "420100",
    "name": "武汉市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4202",
+   "code": "420200",
    "name": "黄石市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4203",
+   "code": "420300",
    "name": "十堰市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4205",
+   "code": "420500",
    "name": "宜昌市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4206",
+   "code": "420600",
    "name": "襄阳市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4207",
+   "code": "420700",
    "name": "鄂州市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4208",
+   "code": "420800",
    "name": "荆门市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4209",
+   "code": "420900",
    "name": "孝感市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4210",
+   "code": "421000",
    "name": "荆州市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4211",
+   "code": "421100",
    "name": "黄冈市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4212",
+   "code": "421200",
    "name": "咸宁市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4213",
+   "code": "421300",
    "name": "随州市",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4228",
+   "code": "422800",
    "name": "恩施土家族苗族自治州",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4290",
+   "code": "429000",
    "name": "省直辖县级行政区划",
-   "provinceCode": "42"
+   "provinceCode": "420000"
   },
   {
-   "code": "4301",
+   "code": "430100",
    "name": "长沙市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4302",
+   "code": "430200",
    "name": "株洲市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4303",
+   "code": "430300",
    "name": "湘潭市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4304",
+   "code": "430400",
    "name": "衡阳市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4305",
+   "code": "430500",
    "name": "邵阳市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4306",
+   "code": "430600",
    "name": "岳阳市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4307",
+   "code": "430700",
    "name": "常德市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4308",
+   "code": "430800",
    "name": "张家界市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4309",
+   "code": "430900",
    "name": "益阳市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4310",
+   "code": "431000",
    "name": "郴州市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4311",
+   "code": "431100",
    "name": "永州市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4312",
+   "code": "431200",
    "name": "怀化市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4313",
+   "code": "431300",
    "name": "娄底市",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4331",
+   "code": "433100",
    "name": "湘西土家族苗族自治州",
-   "provinceCode": "43"
+   "provinceCode": "430000"
   },
   {
-   "code": "4401",
+   "code": "440100",
    "name": "广州市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4402",
+   "code": "440200",
    "name": "韶关市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4403",
+   "code": "440300",
    "name": "深圳市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4404",
+   "code": "440400",
    "name": "珠海市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4405",
+   "code": "440500",
    "name": "汕头市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4406",
+   "code": "440600",
    "name": "佛山市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4407",
+   "code": "440700",
    "name": "江门市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4408",
+   "code": "440800",
    "name": "湛江市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4409",
+   "code": "440900",
    "name": "茂名市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4412",
+   "code": "441200",
    "name": "肇庆市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4413",
+   "code": "441300",
    "name": "惠州市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4414",
+   "code": "441400",
    "name": "梅州市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4415",
+   "code": "441500",
    "name": "汕尾市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4416",
+   "code": "441600",
    "name": "河源市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4417",
+   "code": "441700",
    "name": "阳江市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4418",
+   "code": "441800",
    "name": "清远市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4419",
+   "code": "441900",
    "name": "东莞市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4420",
+   "code": "442000",
    "name": "中山市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4451",
+   "code": "445100",
    "name": "潮州市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4452",
+   "code": "445200",
    "name": "揭阳市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4453",
+   "code": "445300",
    "name": "云浮市",
-   "provinceCode": "44"
+   "provinceCode": "440000"
   },
   {
-   "code": "4501",
+   "code": "450100",
    "name": "南宁市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4502",
+   "code": "450200",
    "name": "柳州市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4503",
+   "code": "450300",
    "name": "桂林市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4504",
+   "code": "450400",
    "name": "梧州市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4505",
+   "code": "450500",
    "name": "北海市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4506",
+   "code": "450600",
    "name": "防城港市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4507",
+   "code": "450700",
    "name": "钦州市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4508",
+   "code": "450800",
    "name": "贵港市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4509",
+   "code": "450900",
    "name": "玉林市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4510",
+   "code": "451000",
    "name": "百色市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4511",
+   "code": "451100",
    "name": "贺州市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4512",
+   "code": "451200",
    "name": "河池市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4513",
+   "code": "451300",
    "name": "来宾市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4514",
+   "code": "451400",
    "name": "崇左市",
-   "provinceCode": "45"
+   "provinceCode": "450000"
   },
   {
-   "code": "4601",
+   "code": "460100",
    "name": "海口市",
-   "provinceCode": "46"
+   "provinceCode": "460000"
   },
   {
-   "code": "4602",
+   "code": "460200",
    "name": "三亚市",
-   "provinceCode": "46"
+   "provinceCode": "460000"
   },
   {
-   "code": "4603",
+   "code": "460300",
    "name": "三沙市",
-   "provinceCode": "46"
+   "provinceCode": "460000"
   },
   {
-   "code": "4604",
+   "code": "460400",
    "name": "儋州市",
-   "provinceCode": "46"
+   "provinceCode": "460000"
   },
   {
-   "code": "4690",
+   "code": "469000",
    "name": "省直辖县级行政区划",
-   "provinceCode": "46"
+   "provinceCode": "460000"
   },
   {
-   "code": "5001",
+   "code": "500100",
    "name": "重庆市",
-   "provinceCode": "50"
+   "provinceCode": "500000"
   },
   {
-   "code": "5002",
+   "code": "500200",
    "name": "重庆市县",
-   "provinceCode": "50"
+   "provinceCode": "500000"
   },
   {
-   "code": "5101",
+   "code": "510100",
    "name": "成都市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5103",
+   "code": "510300",
    "name": "自贡市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5104",
+   "code": "510400",
    "name": "攀枝花市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5105",
+   "code": "510500",
    "name": "泸州市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5106",
+   "code": "510600",
    "name": "德阳市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5107",
+   "code": "510700",
    "name": "绵阳市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5108",
+   "code": "510800",
    "name": "广元市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5109",
+   "code": "510900",
    "name": "遂宁市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5110",
+   "code": "511000",
    "name": "内江市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5111",
+   "code": "511100",
    "name": "乐山市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5113",
+   "code": "511300",
    "name": "南充市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5114",
+   "code": "511400",
    "name": "眉山市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5115",
+   "code": "511500",
    "name": "宜宾市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5116",
+   "code": "511600",
    "name": "广安市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5117",
+   "code": "511700",
    "name": "达州市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5118",
+   "code": "511800",
    "name": "雅安市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5119",
+   "code": "511900",
    "name": "巴中市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5120",
+   "code": "512000",
    "name": "资阳市",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5132",
+   "code": "513200",
    "name": "阿坝藏族羌族自治州",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5133",
+   "code": "513300",
    "name": "甘孜藏族自治州",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5134",
+   "code": "513400",
    "name": "凉山彝族自治州",
-   "provinceCode": "51"
+   "provinceCode": "510000"
   },
   {
-   "code": "5201",
+   "code": "520100",
    "name": "贵阳市",
-   "provinceCode": "52"
+   "provinceCode": "520000"
   },
   {
-   "code": "5202",
+   "code": "520200",
    "name": "六盘水市",
-   "provinceCode": "52"
+   "provinceCode": "520000"
   },
   {
-   "code": "5203",
+   "code": "520300",
    "name": "遵义市",
-   "provinceCode": "52"
+   "provinceCode": "520000"
   },
   {
-   "code": "5204",
+   "code": "520400",
    "name": "安顺市",
-   "provinceCode": "52"
+   "provinceCode": "520000"
   },
   {
-   "code": "5205",
+   "code": "520500",
    "name": "毕节市",
-   "provinceCode": "52"
+   "provinceCode": "520000"
   },
   {
-   "code": "5206",
+   "code": "520600",
    "name": "铜仁市",
-   "provinceCode": "52"
+   "provinceCode": "520000"
   },
   {
-   "code": "5223",
+   "code": "522300",
    "name": "黔西南布依族苗族自治州",
-   "provinceCode": "52"
+   "provinceCode": "520000"
   },
   {
-   "code": "5226",
+   "code": "522600",
    "name": "黔东南苗族侗族自治州",
-   "provinceCode": "52"
+   "provinceCode": "520000"
   },
   {
-   "code": "5227",
+   "code": "522700",
    "name": "黔南布依族苗族自治州",
-   "provinceCode": "52"
+   "provinceCode": "520000"
   },
   {
-   "code": "5301",
+   "code": "530100",
    "name": "昆明市",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5303",
+   "code": "530300",
    "name": "曲靖市",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5304",
+   "code": "530400",
    "name": "玉溪市",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5305",
+   "code": "530500",
    "name": "保山市",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5306",
+   "code": "530600",
    "name": "昭通市",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5307",
+   "code": "530700",
    "name": "丽江市",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5308",
+   "code": "530800",
    "name": "普洱市",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5309",
+   "code": "530900",
    "name": "临沧市",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5323",
+   "code": "532300",
    "name": "楚雄彝族自治州",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5325",
+   "code": "532500",
    "name": "红河哈尼族彝族自治州",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5326",
+   "code": "532600",
    "name": "文山壮族苗族自治州",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5328",
+   "code": "532800",
    "name": "西双版纳傣族自治州",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5329",
+   "code": "532900",
    "name": "大理白族自治州",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5331",
+   "code": "533100",
    "name": "德宏傣族景颇族自治州",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5333",
+   "code": "533300",
    "name": "怒江傈僳族自治州",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5334",
+   "code": "533400",
    "name": "迪庆藏族自治州",
-   "provinceCode": "53"
+   "provinceCode": "530000"
   },
   {
-   "code": "5401",
+   "code": "540100",
    "name": "拉萨市",
-   "provinceCode": "54"
+   "provinceCode": "540000"
   },
   {
-   "code": "5402",
+   "code": "540200",
    "name": "日喀则市",
-   "provinceCode": "54"
+   "provinceCode": "540000"
   },
   {
-   "code": "5403",
+   "code": "540300",
    "name": "昌都市",
-   "provinceCode": "54"
+   "provinceCode": "540000"
   },
   {
-   "code": "5404",
+   "code": "540400",
    "name": "林芝市",
-   "provinceCode": "54"
+   "provinceCode": "540000"
   },
   {
-   "code": "5405",
+   "code": "540500",
    "name": "山南市",
-   "provinceCode": "54"
+   "provinceCode": "540000"
   },
   {
-   "code": "5406",
+   "code": "540600",
    "name": "那曲市",
-   "provinceCode": "54"
+   "provinceCode": "540000"
   },
   {
-   "code": "5425",
+   "code": "542500",
    "name": "阿里地区",
-   "provinceCode": "54"
+   "provinceCode": "540000"
   },
   {
-   "code": "6101",
+   "code": "610100",
    "name": "西安市",
-   "provinceCode": "61"
+   "provinceCode": "610000"
   },
   {
-   "code": "6102",
+   "code": "610200",
    "name": "铜川市",
-   "provinceCode": "61"
+   "provinceCode": "610000"
   },
   {
-   "code": "6103",
+   "code": "610300",
    "name": "宝鸡市",
-   "provinceCode": "61"
+   "provinceCode": "610000"
   },
   {
-   "code": "6104",
+   "code": "610400",
    "name": "咸阳市",
-   "provinceCode": "61"
+   "provinceCode": "610000"
   },
   {
-   "code": "6105",
+   "code": "610500",
    "name": "渭南市",
-   "provinceCode": "61"
+   "provinceCode": "610000"
   },
   {
-   "code": "6106",
+   "code": "610600",
    "name": "延安市",
-   "provinceCode": "61"
+   "provinceCode": "610000"
   },
   {
-   "code": "6107",
+   "code": "610700",
    "name": "汉中市",
-   "provinceCode": "61"
+   "provinceCode": "610000"
   },
   {
-   "code": "6108",
+   "code": "610800",
    "name": "榆林市",
-   "provinceCode": "61"
+   "provinceCode": "610000"
   },
   {
-   "code": "6109",
+   "code": "610900",
    "name": "安康市",
-   "provinceCode": "61"
+   "provinceCode": "610000"
   },
   {
-   "code": "6110",
+   "code": "611000",
    "name": "商洛市",
-   "provinceCode": "61"
+   "provinceCode": "610000"
   },
   {
-   "code": "6201",
+   "code": "620100",
    "name": "兰州市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6202",
+   "code": "620200",
    "name": "嘉峪关市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6203",
+   "code": "620300",
    "name": "金昌市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6204",
+   "code": "620400",
    "name": "白银市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6205",
+   "code": "620500",
    "name": "天水市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6206",
+   "code": "620600",
    "name": "武威市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6207",
+   "code": "620700",
    "name": "张掖市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6208",
+   "code": "620800",
    "name": "平凉市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6209",
+   "code": "620900",
    "name": "酒泉市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6210",
+   "code": "621000",
    "name": "庆阳市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6211",
+   "code": "621100",
    "name": "定西市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6212",
+   "code": "621200",
    "name": "陇南市",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6229",
+   "code": "622900",
    "name": "临夏回族自治州",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6230",
+   "code": "623000",
    "name": "甘南藏族自治州",
-   "provinceCode": "62"
+   "provinceCode": "620000"
   },
   {
-   "code": "6301",
+   "code": "630100",
    "name": "西宁市",
-   "provinceCode": "63"
+   "provinceCode": "630000"
   },
   {
-   "code": "6302",
+   "code": "630200",
    "name": "海东市",
-   "provinceCode": "63"
+   "provinceCode": "630000"
   },
   {
-   "code": "6322",
+   "code": "632200",
    "name": "海北藏族自治州",
-   "provinceCode": "63"
+   "provinceCode": "630000"
   },
   {
-   "code": "6323",
+   "code": "632300",
    "name": "黄南藏族自治州",
-   "provinceCode": "63"
+   "provinceCode": "630000"
   },
   {
-   "code": "6325",
+   "code": "632500",
    "name": "海南藏族自治州",
-   "provinceCode": "63"
+   "provinceCode": "630000"
   },
   {
-   "code": "6326",
+   "code": "632600",
    "name": "果洛藏族自治州",
-   "provinceCode": "63"
+   "provinceCode": "630000"
   },
   {
-   "code": "6327",
+   "code": "632700",
    "name": "玉树藏族自治州",
-   "provinceCode": "63"
+   "provinceCode": "630000"
   },
   {
-   "code": "6328",
+   "code": "632800",
    "name": "海西蒙古族藏族自治州",
-   "provinceCode": "63"
+   "provinceCode": "630000"
   },
   {
-   "code": "6401",
+   "code": "640100",
    "name": "银川市",
-   "provinceCode": "64"
+   "provinceCode": "640000"
   },
   {
-   "code": "6402",
+   "code": "640200",
    "name": "石嘴山市",
-   "provinceCode": "64"
+   "provinceCode": "640000"
   },
   {
-   "code": "6403",
+   "code": "640300",
    "name": "吴忠市",
-   "provinceCode": "64"
+   "provinceCode": "640000"
   },
   {
-   "code": "6404",
+   "code": "640400",
    "name": "固原市",
-   "provinceCode": "64"
+   "provinceCode": "640000"
   },
   {
-   "code": "6405",
+   "code": "640500",
    "name": "中卫市",
-   "provinceCode": "64"
+   "provinceCode": "640000"
   },
   {
-   "code": "6501",
+   "code": "650100",
    "name": "乌鲁木齐市",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6502",
+   "code": "650200",
    "name": "克拉玛依市",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6504",
+   "code": "650400",
    "name": "吐鲁番市",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6505",
+   "code": "650500",
    "name": "哈密市",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6523",
+   "code": "652300",
    "name": "昌吉回族自治州",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6527",
+   "code": "652700",
    "name": "博尔塔拉蒙古自治州",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6528",
+   "code": "652800",
    "name": "巴音郭楞蒙古自治州",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6529",
+   "code": "652900",
    "name": "阿克苏地区",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6530",
+   "code": "653000",
    "name": "克孜勒苏柯尔克孜自治州",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6531",
+   "code": "653100",
    "name": "喀什地区",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6532",
+   "code": "653200",
    "name": "和田地区",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6540",
+   "code": "654000",
    "name": "伊犁哈萨克自治州",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6542",
+   "code": "654200",
    "name": "塔城地区",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6543",
+   "code": "654300",
    "name": "阿勒泰地区",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   },
   {
-   "code": "6590",
+   "code": "659000",
    "name": "自治区直辖县级行政区划",
-   "provinceCode": "65"
+   "provinceCode": "650000"
   }
  ],
  "t2s": {

+ 2 - 2
java/src/test/java/com/payment/platform/core/security/HarCheckoutWiringTest.java

@@ -66,12 +66,12 @@ class HarCheckoutWiringTest {
      * 反面对照:管理端(要登录)的汇安融路径**不得**被白名单覆盖。
      *
      * <p>★ 否定式读数配正对照:先用一条**确实被放行**的同前缀路径证明匹配器与读取姿势是好的
-     * ({@code /payment/openapi/har/**} 命中),再断言管理端两条不被命中 ——
+     * ({@code /payment/openapi/alipay/**} 命中),再断言管理端两条不被命中 ——
      * 否则「没命中」可能只是量具坏了(本仓吃过这个亏)。
      */
     @Test
     void adminHarEndpoints_areNotWhitelisted() throws Exception {
-        assertTrue(whitelisted("/payment/openapi/har/barcode/pay"),
+        assertTrue(whitelisted("/payment/openapi/alipay/barcode/pay"),
                 "正对照:同前缀的开放接口确实被放行 —— 证明匹配器读取姿势是好的");
 
         assertFalse(whitelisted("/payment/har/merchant"),

+ 96 - 0
java/src/test/java/com/payment/platform/core/security/OpenapiAuthWiringTest.java

@@ -0,0 +1,96 @@
+package com.payment.platform.core.security;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 白名单 × 认证前缀的**一致性**守卫。
+ *
+ * <p>凡是 {@code SecurityConfig.WHITE_LIST} 里以 {@code /payment/openapi/} 开头、
+ * 被 permitAll 放行的前缀,{@code TenantApiKeyAuthFilter} 必须同样认领它
+ * ({@code shouldNotFilter} 返回 false = 需要 API Key 认证)。
+ *
+ * <p><b>为什么单开一类</b>:2026-09-24 的零鉴权事故就是这两份清单**只改了一边** ——
+ * 白名单放行了 {@code /payment/openapi/har/**}、filter 的 {@code OPENAPI_PREFIXES}
+ * 没加,该前缀下「被放行 + 没有任何 filter 认证」,而当时**没有任何测试会变红**
+ * (当事注释原文,见 {@code OUTPUT_PREFIXES} 上方那段)。2026-10-09 前缀改名为
+ * {@code alipay} 又是一次「两处必须同名改」的动作,所以把这条一致性从注释里的
+ * 告诫升级成一个会变红的用例。
+ *
+ * <p><b>本条不是重复劳动</b>:{@code HarCheckoutWiringTest} 守的是「顾客页被放行」,
+ * {@code LeshouyingWiringTest} 守的是「乐收赢路径需要认证」,两者都按**字面量**列举
+ * 自己那条通道;没有任何一条用例读 {@code WHITE_LIST} 再回头找 filter 对账。
+ * (变异验证 2026-10-09:只把 {@code OPENAPI_PREFIXES} 的 alipay 那条改回 har、
+ * 白名单保持 alipay —— 改前 30 个既有 wiring 用例**全绿**,本类是本条修法的红点。)
+ */
+class OpenapiAuthWiringTest {
+
+    /** 被测 filter;三个依赖字段在 shouldNotFilter 路径上都不被触碰,故可传 null(同 LeshouyingWiringTest)。 */
+    private final TenantApiKeyAuthFilter filter = new TenantApiKeyAuthFilter(null, null, null);
+
+    /**
+     * 遍历白名单里每一条 openapi 前缀:permitAll 放行的,filter 必须认领(不跳过)。
+     *
+     * <p>bare 前缀(不带尾段)与带尾段两种形状都断言:filter 的判定式是
+     * {@code equals(prefix) || startsWith(prefix + "/")},两半各对应其中一种。
+     */
+    @Test
+    void everyPermitAllOpenapiWhitelistEntry_isClaimedByTheAuthFilter() throws Exception {
+        String[] whiteList = readWhiteList();
+
+        int checked = 0;
+        for (String entry : whiteList) {
+            if (!entry.startsWith("/payment/openapi/")) {
+                continue;
+            }
+            String bare = entry.endsWith("/**")
+                    ? entry.substring(0, entry.length() - 3) : entry;
+            checked++;
+            assertFalse(filter.shouldNotFilter(realRequest(bare)),
+                    "白名单放行 " + entry + " 而 filter 不认这个前缀 ⇒ 该前缀下零鉴权(2026-09-24 事故形状): " + bare);
+            assertFalse(filter.shouldNotFilter(realRequest(bare + "/probe")),
+                    "同上(带尾段形状): " + bare + "/probe");
+        }
+        assertTrue(checked >= 3,
+                "一条 /payment/openapi/ 白名单都没扫到,说明这条用例的量具坏了(读数 0 不算通过): checked=" + checked);
+    }
+
+    /**
+     * 正对照:同一支 filter 对**免登录顾客页**必须跳过(true)。
+     *
+     * <p>少了这条,「filter 恒返回 false」的坏量具也能让上面整条用例全绿 ——
+     * 那种形状下断言虽然全中,却什么都没守住。
+     */
+    @Test
+    void positiveControl_checkoutPageIsSkipped() {
+        assertTrue(filter.shouldNotFilter(realRequest("/payment/har/checkout/tok")),
+                "顾客页不是下游系统,filter 必须跳过 —— 正对照,证明本量具能给出 true");
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private static String[] readWhiteList() throws Exception {
+        Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST");
+        f.setAccessible(true);
+        return (String[]) f.get(null);
+    }
+
+    /**
+     * 构造**真实容器形状**的请求:servletPath 不含 context-path,requestURI 含。
+     * ★ 若两者相等(旧写法),测试就分辨不出被测代码用的是哪个访问器 ——
+     *   同 LeshouyingWiringTest#realRequest。
+     */
+    private static MockHttpServletRequest realRequest(String path) {
+        MockHttpServletRequest req = new MockHttpServletRequest("POST", path);
+        req.setContextPath("/api/v1");
+        req.setServletPath(path);
+        req.setRequestURI("/api/v1" + path);
+        assertNotEquals(req.getServletPath(), req.getRequestURI(),
+                "mock 必须是真实容器形状:servletPath ≠ requestURI,否则测不出访问器用错");
+        return req;
+    }
+}

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

@@ -107,7 +107,7 @@ class HarCheckoutControllerTest {
     }
 
     /**
-     * 让这条腿一路走到下单:IP 解析成江苏南京,并按 (省 32, 市 3201) 挑到一家店。
+     * 让这条腿一路走到下单:IP 解析成江苏南京,并按 (省 320000, 市 320100) 挑到一家店。
      *
      * @return 被挑中的那家门店(用例要断言它的 {@code third_shop_code} 时取用)
      */
@@ -115,7 +115,7 @@ class HarCheckoutControllerTest {
         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);
+        when(shopService.pickForRegion(MCH, "320000", "320100", IP)).thenReturn(shop);
         return shop;
     }
 
@@ -262,7 +262,7 @@ class HarCheckoutControllerTest {
      * **√ 必填**,{@code shop_code}(平台门店编号)才是选填;而按顾客所在地挑店正是本功能的
      * 全部意义 —— 用错地区的门店下单,支付宝会按异地收款风控拦。
      *
-     * <p>stub 写的是**精确匹配**的 {@code (MCH,"32","3201",IP)} ⇒ 参数错一个,它返回 null,
+     * <p>stub 写的是**精确匹配**的 {@code (MCH,"320000","320100",IP)} ⇒ 参数错一个,它返回 null,
      * 这条腿当场 400,断言立刻红。
      */
     @Test
@@ -276,7 +276,7 @@ class HarCheckoutControllerTest {
         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);
+        verify(shopService).pickForRegion(MCH, "320000", "320100", IP);
     }
 
     /**
@@ -288,7 +288,7 @@ class HarCheckoutControllerTest {
         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(shopService.pickForRegion(MCH, "500000", null, IP)).thenReturn(shopWith("SHOP-CQ"));
         when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
 
         controller.create("tok", pay("5"), request());
@@ -304,7 +304,7 @@ class HarCheckoutControllerTest {
         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(shopService.pickForRegion(MCH, "540000", "540100", IP)).thenReturn(shopWith("SHOP-XZ"));
         when(orderService.create(any(), any(), any())).thenReturn(paidVo("https://pay/x"));
 
         controller.create("tok", pay("5"), request());
@@ -357,7 +357,7 @@ class HarCheckoutControllerTest {
     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);
+        when(shopService.pickForRegion(MCH, "320000", "320100", IP)).thenReturn(null);
 
         BusinessException ex = assertThrows(BusinessException.class,
                 () -> controller.create("tok", pay("5"), request()));
@@ -377,7 +377,7 @@ class HarCheckoutControllerTest {
     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 -> {
+        when(shopService.pickForRegion(MCH, "320000", "320100", IP)).thenAnswer(inv -> {
             assertEquals(TENANT, currentTenantId(), "挑店读的是 pay_har_shop,必须带租户");
             return shopWith("SHOP-NJ");
         });
@@ -439,7 +439,7 @@ class HarCheckoutControllerTest {
             return null;
         });
         // 挑店读的是 pay_har_shop,它也在 CONDITIONAL_TABLES 里 —— 这一跳同样要在租户身份下。
-        when(shopService.pickForRegion(MCH, "32", "3201", IP)).thenAnswer(inv -> {
+        when(shopService.pickForRegion(MCH, "320000", "320100", IP)).thenAnswer(inv -> {
             seen.add(currentTenantId());
             return shopWith("SHOP-NJ");
         });

+ 5 - 5
java/src/test/java/com/payment/platform/module/payment/har/controller/HarMerchantControllerTest.java

@@ -132,7 +132,7 @@ class HarMerchantControllerTest {
         when(linkService.issue(3L, "M003", "商户表里的名字"))
                 .thenReturn(link("M003", "链接行里的名字", "tok3"));
         when(linkService.buildUrl(same("tok3"), same(request)))
-                .thenReturn("https://pay.example.com/#/checkout/har/tok3");
+                .thenReturn("https://pay.example.com/#/checkout/alipay/tok3");
 
         HarCheckoutLinkVO vo = controller.checkoutLink(1L).getData();
 
@@ -142,7 +142,7 @@ class HarMerchantControllerTest {
         assertEquals("M003", vo.getMerchantCode(), "商户编号要回给页面显示(操作员得知道自己在给谁取链接)");
         assertEquals("链接行里的名字", vo.getMerchantName(),
                 "显示名取自**链接行**(顾客打开链接时看到的就是它),不是商户行那一列");
-        assertEquals("https://pay.example.com/#/checkout/har/tok3", vo.getUrl(),
+        assertEquals("https://pay.example.com/#/checkout/alipay/tok3", vo.getUrl(),
                 "URL 必须来自服务层的 buildUrl(基址推导那套逻辑只有一份,"
                         + "控制器自己拼一个就是第二个副本)");
     }
@@ -164,14 +164,14 @@ class HarMerchantControllerTest {
         when(merchantService.requireById(1L)).thenReturn(row);
         when(linkService.issue(5L, "M005", "五号商户")).thenReturn(link("M005", "五号商户", "tok5"));
         when(linkService.buildUrl(same("tok5"), same(request)))
-                .thenReturn("https://pay.example.com/#/checkout/har/tok5");
+                .thenReturn("https://pay.example.com/#/checkout/alipay/tok5");
 
         HarCheckoutLinkVO vo = controller.checkoutLink(1L).getData();
 
         verify(merchantService).requireById(1L);
         verify(merchantService, never()).requireByIdForTenant(anyLong(), any());
         verify(linkService).issue(5L, "M005", "五号商户");
-        assertEquals("https://pay.example.com/#/checkout/har/tok5", vo.getUrl(),
+        assertEquals("https://pay.example.com/#/checkout/alipay/tok5", vo.getUrl(),
                 "超管没有自己的租户号 —— 租户号必须取自那一行,两种调用方才都通");
     }
 
@@ -199,7 +199,7 @@ class HarMerchantControllerTest {
                 .thenReturn(merchant(1L, null, "M001", null));
         when(linkService.issue(null, "M001", null)).thenReturn(link("M001", null, "tok1"));
         when(linkService.buildUrl(same("tok1"), same(request)))
-                .thenReturn("https://pay.example.com/#/checkout/har/tok1");
+                .thenReturn("https://pay.example.com/#/checkout/alipay/tok1");
 
         controller.checkoutLink(1L);
 

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

@@ -44,7 +44,7 @@ class HarRegionControllerTest {
     private final HarRegionCatalog catalog = new HarRegionCatalog();
     private final HarRegionController controller = new HarRegionController(catalog);
 
-    /** 按码取名字;取不到就把**实际拿到的码**全打出来 —— 否则失败信息只有一句「没有 32」 */
+    /** 按码取名字;取不到就把**实际拿到的码**全打出来 —— 否则失败信息只有一句「没有 320000」 */
     private static String nameOf(List<HarRegionVO> rows, String code) {
         return rows.stream()
                 .filter(v -> code.equals(v.getCode()))
@@ -66,11 +66,11 @@ class HarRegionControllerTest {
                 "省级 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 的形状;选择器回填的就是这一格,缺一个字符都拼不上");
+        assertEquals("北京市", nameOf(rows, "110000"));
+        assertEquals("江苏省", nameOf(rows, "320000"));
+        assertEquals("新疆维吾尔自治区", nameOf(rows, "650000"));
+        assertTrue(rows.stream().allMatch(v -> v.getCode() != null && v.getCode().length() == 6),
+                "六位省码是 GB/T 2260 完整码的形状;选择器回填的就是这一格,缺一个字符都拼不上");
         assertTrue(rows.stream().allMatch(v -> v.getName() != null && !v.getName().isBlank()),
                 "名字要直接上屏,空名字在下拉里是一个看不见的空行");
     }
@@ -79,26 +79,26 @@ class HarRegionControllerTest {
 
     @Test
     void cities_returnsOnlyThatProvincesCities() {
-        Result<List<HarRegionVO>> r = controller.cities("32");
+        Result<List<HarRegionVO>> r = controller.cities("320000");
         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),
-                "四位市码");
+        assertEquals("南京市", nameOf(rows, "320100"));
+        assertTrue(rows.stream().allMatch(v -> v.getCode().length() == 6),
+                "六位市码");
         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())),
+        assertTrue(catalog.cities("340000").stream().anyMatch(c -> "340100".equals(c.code())),
+                "正对照:340100 是安徽的合肥市,目录里有");
+        assertFalse(rows.stream().anyMatch(v -> "340100".equals(v.getCode())),
                 "安徽的市不许出现在江苏的列表里");
 
         // 空白是查询串里最常见的手滑(手填 URL / 复制粘贴带空格)—— 认得出就别报 400
-        assertEquals(13, controller.cities(" 32 ").getData().size(),
+        assertEquals(13, controller.cities(" 320000 ").getData().size(),
                 "带空格的省码要先 trim 再查表,不能直接拿去比对");
     }
 
@@ -110,10 +110,10 @@ class HarRegionControllerTest {
      */
     @Test
     void cities_unknownProvinceCode_400_notAnEmptyList() {
-        BusinessException ex = assertThrows(BusinessException.class, () -> controller.cities("99"));
+        BusinessException ex = assertThrows(BusinessException.class, () -> controller.cities("999999"));
 
         assertEquals(400, ex.getCode());
-        assertEquals("省份编码不存在: 99", ex.getMessage(),
+        assertEquals("省份编码不存在: 999999", ex.getMessage(),
                 "措辞与 HarShopService#requireKnownCity 同源 —— 同一类错误在两处该是同一句话");
     }
 

+ 2 - 2
java/src/test/java/com/payment/platform/module/payment/har/controller/open/HarOpenControllerTest.java

@@ -212,8 +212,8 @@ class HarOpenControllerTest {
         assertEquals("/refund", HarOpenController.class
                         .getDeclaredMethod("refund", HarOpenRefundDTO.class)
                         .getAnnotation(PostMapping.class).value()[0],
-                "租户文档 10.4 承诺的路径是 /payment/openapi/har/refund");
-        assertEquals("/payment/openapi/har",
+                "租户文档 10.4 承诺的路径是 /payment/openapi/alipay/refund");
+        assertEquals("/payment/openapi/alipay",
                 HarOpenController.class.getAnnotation(RequestMapping.class).value()[0]);
     }
 

+ 25 - 1
java/src/test/java/com/payment/platform/module/payment/har/dto/HarDtoJsonContractTest.java

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.payment.platform.common.base.PaymentBaseEntity;
 import com.payment.platform.common.config.JacksonConfig;
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -12,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
- * 钉住汇安融三个管理端 VO 的 {@code id} 序列化形态:必须是 JSON <b>字符串</b>。
+ * 钉住汇安融管理端 VO 的 JSON 形态:{@code id} 必须是字符串,订单响应体不得暴露 {@code biz}。
  *
  * <p>为什么值得一个测试:{@link PaymentBaseEntity} 的 {@code id} 是
  * {@code IdType.ASSIGN_ID}(雪花,19 位),超过 JS 的 {@code Number.MAX_SAFE_INTEGER}
@@ -96,6 +97,29 @@ class HarDtoJsonContractTest {
         assertIdIsExactString(jsonOf(vo));
     }
 
+    /**
+     * 订单响应体**不含 biz 键**(2026-10-09 去上游化)。
+     *
+     * <p>那串取值带上游品牌(规范 260924 的 {@code har_ali_un_stage}),且自从平台按
+     * {@code trade_type} 自动派生后,调用方既不用填也不该看到;管理页也没有任何一列读它
+     * (表单仍可显式传 —— 那是透传,不是回读)。
+     *
+     * <p>断言「键不在报文里」而不是「值为 null」:值为 null 的字段照样占着这个键、
+     * 出现在文档与 SDK 生成器里 —— 那还是暴露。夹具特意给实体填上非空 biz,
+     * 证明这条钉的是 VO 的字段集,不是「数据库里恰好没有」。
+     */
+    @Test
+    void orderVoResponse_doesNotCarryBizKey() {
+        HarOrderEntity e = new HarOrderEntity();
+        e.setOrderNo("AP1");
+        e.setBiz("har_ali_un_stage");
+        HarOrderVO vo = HarOrderVO.of(e);
+
+        JsonNode node = jsonOf(vo);
+        assertTrue(node.path("biz").isMissingNode(),
+                "响应体不得再出现 biz 键。实测报文: " + node);
+    }
+
     /**
      * {@code isTextual} 才是承重的那条:数值节点上调 {@code asText()} 同样拿得到
      * 正确数字串(Java 侧没丢精度,丢精度发生在浏览器),只断言 {@code asText()}

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

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

+ 37 - 1
java/src/test/java/com/payment/platform/module/payment/har/scheduler/HarOrderPollSchedulerTest.java

@@ -3,6 +3,7 @@ package com.payment.platform.module.payment.har.scheduler;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.har.config.HarProperties;
 import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.service.HarCallbackService;
 import com.payment.platform.module.payment.har.service.HarOrderService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -20,6 +21,7 @@ import static org.mockito.Mockito.*;
 class HarOrderPollSchedulerTest {
 
     @Mock private HarOrderService orderService;
+    @Mock private HarCallbackService callbackService;
 
     private HarProperties properties;
     private HarOrderPollScheduler scheduler;
@@ -27,7 +29,7 @@ class HarOrderPollSchedulerTest {
     @BeforeEach
     void setUp() {
         properties = new HarProperties();
-        scheduler = new HarOrderPollScheduler(orderService, properties);
+        scheduler = new HarOrderPollScheduler(orderService, properties, callbackService);
     }
 
     private HarOrderEntity order(String orderNo) {
@@ -95,4 +97,38 @@ class HarOrderPollSchedulerTest {
         assertDoesNotThrow(scheduler::pollPendingOrders);
         verify(orderService, never()).syncFromUpstream(any());
     }
+
+    // ==================== 失败回调重试(2026-10-09 补齐) ====================
+
+    /**
+     * 重试任务的闸门与轮询一致(har.enabled)。关着的时候连扫都不该扫 ——
+     * 密钥没配好时它每轮都会白扫一张表。
+     */
+    @Test
+    void retryCallbacks_disabled_doesNotScan() {
+        properties.setEnabled(false);
+
+        scheduler.retryFailedCallbacks();
+
+        verify(callbackService, never()).retryFailed();
+    }
+
+    /** 正对照:开着的时候真的要扫(上面那条 never() 才有意义) */
+    @Test
+    void retryCallbacks_enabled_invokesCallbackRetry() {
+        properties.setEnabled(true);
+
+        scheduler.retryFailedCallbacks();
+
+        verify(callbackService).retryFailed();
+    }
+
+    /** 重试整批炸了不能掀翻调度线程 —— 它上面还挂着轮询主任务(状态收敛的主路径) */
+    @Test
+    void retryCallbacks_throws_doesNotPropagate() {
+        properties.setEnabled(true);
+        doThrow(new BusinessException("数据库连接失败")).when(callbackService).retryFailed();
+
+        assertDoesNotThrow(scheduler::retryFailedCallbacks);
+    }
 }

+ 324 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarCallbackServiceTest.java

@@ -0,0 +1,324 @@
+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.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity;
+import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
+import com.payment.platform.module.payment.har.config.HarProperties;
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.enums.HarNotifyStatus;
+import com.payment.platform.module.payment.har.enums.HarOrderStatus;
+import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
+import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper;
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.AfterEach;
+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 org.springframework.http.HttpEntity;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.client.RestTemplate;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 下游商家回调的推送与重试契约(2026-10-09 补齐)。
+ *
+ * 【三条主断言与它们各自防的回归】
+ * ① 缺地址**记 FAILED 落库**而不是打日志就跳过 —— 旧实现停在 PENDING,
+ *    而重试扫描的入口条件是 notify_status='FAILED',商户事后补配地址也永远
+ *    等不到这一笔(见 {@link HarCallbackServiceTest#notifyEnterprise_noUrl_recordsFailedForRetryInsteadOfSkipping})。
+ * ② 推送**单次**,线程内不再退避重试 —— 旧实现在回调线程里睡 1s、2s 共 3 次,
+ *    一个卡住的商户地址会占满回调池,而上游通知还等着我们尽快返回 success。
+ * ③ {@link HarCallbackService#retryFailed} 的扫描形状五要素(状态字面量、
+ *    计数上界、排序方向、LIMIT、取值来源)—— 任一写错都是「重试静默失效而套件全绿」。
+ *
+ * 【为什么断言 SET 段而不是实体】写库刻意**不走实体**(实体带 @Version:回调线程
+ * 可能在 syncFromUpstream 事务提交前读到旧行版本,updateById 会静默丢写)⇒ 只能走
+ * UpdateWrapper,断言也只能拆 SET 子句。取值断言用 setValueOf(只看 SET 段),
+ * WHERE 的绑定值用 whereBoundValues 单独看 —— 两张表混在一起时 contains 会假绿
+ * (同 HarOrderServiceTest 里那两段注释的理由)。
+ */
+@ExtendWith(MockitoExtension.class)
+class HarCallbackServiceTest {
+
+    private static final Long TENANT = 7L;
+
+    @Mock private TenantApiKeyMapper apiKeyMapper;
+    @Mock private OpenConfMapper confMapper;
+    @Mock private HarOrderMapper orderMapper;
+    @Mock private RestTemplate restTemplate;
+    @Mock private OrderEventPublisher publisher;
+
+    private HarProperties props;
+    private HarCallbackService service;
+
+    @BeforeEach
+    void setUp() {
+        props = new HarProperties();
+        props.setCallbackMaxRetries(8);
+        service = new HarCallbackService(apiKeyMapper, confMapper, orderMapper, restTemplate, props, publisher);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
+        // (同款引导见 LsyCallbackServiceTest / HarOrderServiceTest。)
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarOrderEntity.class);
+    }
+
+    @AfterEach
+    void tearDown() {
+        // 回调线程池是 service 自有的(非 Spring 管理),单测里手动收,避免线程泄漏影响后续用例
+        service.shutdown();
+    }
+
+    /** 夹具订单:只给「哪里都没配回调地址」用得着的三格 */
+    private HarOrderEntity order(String apiKey) {
+        HarOrderEntity o = new HarOrderEntity();
+        o.setOrderNo("HAR1");
+        o.setTenantId(TENANT);
+        o.setTradeStatus(HarOrderStatus.PAID);
+        o.setApiKey(apiKey);
+        return o;
+    }
+
+    /** 把「Key 上有回调地址」这一前提收成一行 */
+    private void stubKeyWithUrl(String url) {
+        TenantApiKeyEntity key = new TenantApiKeyEntity();
+        key.setReturnUrl(url);
+        when(apiKeyMapper.selectOne(any())).thenReturn(key);
+    }
+
+    private void stubPushOk() {
+        when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
+                .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK));
+    }
+
+    /**
+     * 缺地址不再静默跳过 —— 记 FAILED 落库,交给调度器重试。
+     *
+     * 【为什么这条是主断言】旧实现只 log 一句就 return(notify_status 停在 PENDING),
+     * 商户事后补配了地址也永远等不到这一笔:重试扫描的条件是 notify_status='FAILED'。
+     * 而 notify_count / last_notify_time 必须一起落:前者是重试的封顶计数,后者是
+     * 扫描的排序键 —— 少一个,重试扫描要么捞不到、要么排不对。
+     */
+    @Test
+    void notifyEnterprise_noUrl_recordsFailedForRetryInsteadOfSkipping() {
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.notifyEnterprise(order(null));   // apiKey 为空、租户也没配 ⇒ 哪里都没地址
+
+        verify(restTemplate, never()).postForEntity(anyString(), any(), eq(String.class));
+
+        ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper).update(isNull(), w.capture());
+        assertEquals(HarNotifyStatus.FAILED, setValueOf(w.getValue(), "notify_status"),
+                "缺地址也要落 FAILED(重试扫描的入口条件)。实测 SET: " + w.getValue().getSqlSet());
+        assertTrue(w.getValue().getSqlSet().contains("notify_count = COALESCE(notify_count, 0) + 1"),
+                "计数要在 SQL 侧自增(并发重试时 read-modify-write 会丢计数,计数丢了上限形同虚设)。实测 SET: "
+                        + w.getValue().getSqlSet());
+        assertNotNull(setValueOf(w.getValue(), "last_notify_time"),
+                "排序键不能为空。实测 SET: " + w.getValue().getSqlSet());
+        assertNotNull(setValueOf(w.getValue(), "updated_time"),
+                "update(null,wrapper) 不经自动填充,updated_time 要手写。实测 SET: " + w.getValue().getSqlSet());
+        assertTrue(whereBoundValues(w.getValue()).contains("HAR1"),
+                "WHERE 必须钉住这一单。实测: " + w.getValue().getSqlSegment());
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+    }
+
+    /** 有地址、推送 2xx ⇒ 记 SUCCESS + 计数 + 订单信号 */
+    @Test
+    void notifyEnterprise_pushOk_recordsSuccessWithCountAndSignal() {
+        stubKeyWithUrl("https://merchant.example.com/cb");
+        stubPushOk();
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.notifyEnterprise(order("K1"));
+
+        ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper, timeout(3000)).update(isNull(), w.capture());
+        assertEquals(HarNotifyStatus.SUCCESS, setValueOf(w.getValue(), "notify_status"),
+                "2xx ⇒ SUCCESS。实测 SET: " + w.getValue().getSqlSet());
+        verify(restTemplate).postForEntity(eq("https://merchant.example.com/cb"),
+                any(HttpEntity.class), eq(String.class));
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+    }
+
+    /**
+     * 推送抛异常 ⇒ 记 FAILED,且**恰好一次 POST** —— 线程内不重试。
+     *
+     * 旧实现在回调线程里睡 1s、2s 重试共 3 次;这条用例把「重试交给调度器」钉死,
+     * 它同时也挡住「顺手再加个退避循环」的回归(times(1) 会红)。
+     */
+    @Test
+    void notifyEnterprise_pushThrows_recordsFailedWithSingleAttemptInThread() {
+        stubKeyWithUrl("https://merchant.example.com/cb");
+        when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
+                .thenThrow(new RuntimeException("connection refused"));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.notifyEnterprise(order("K1"));
+
+        ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper, timeout(3000)).update(isNull(), w.capture());
+        assertEquals(HarNotifyStatus.FAILED, setValueOf(w.getValue(), "notify_status"),
+                "实测 SET: " + w.getValue().getSqlSet());
+        verify(restTemplate, times(1)).postForEntity(anyString(), any(HttpEntity.class), eq(String.class));
+    }
+
+    /**
+     * {@code retryFailed()} 的扫描形状 —— 与乐收赢 LsyCallbackService#retryFailed 对齐。
+     *
+     * 【为什么五个要素全钉】状态字面量、计数上界、排序方向、LIMIT 任一写错,症状都是
+     * 「失败回调永远不被重试而套件全绿」—— 没有别的任务会断言这个查询。
+     * 【取值为什么定 3 而不是默认 8】钉住上界来自 HarProperties 而不是写死的数字。
+     * 【断言为什么分两半】MyBatis-Plus 的 .eq()/.lt() 不把值写进 SQL 文本(塞进
+     * paramNameValuePairs)⇒ 结构看 getSqlSegment(),取值看参数表,取值用**恰好相等**。
+     */
+    @Test
+    void retryFailed_pinsScanQuery() {
+        props.setCallbackMaxRetries(3);
+        when(orderMapper.selectList(any())).thenReturn(List.of());
+
+        service.retryFailed();
+
+        ArgumentCaptor<LambdaQueryWrapper> scan = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(orderMapper).selectList(scan.capture());
+        LambdaQueryWrapper<?> w = scan.getValue();
+        String sql = w.getSqlSegment();
+
+        assertEquals(Set.of(HarNotifyStatus.FAILED, 3), new HashSet<>(w.getParamNameValuePairs().values()),
+                "取值必须恰好是 {FAILED, 上限},实测: " + w.getParamNameValuePairs());
+        assertTrue(sql.contains("notify_status = #{"),
+                "必须按 notify_status 等值过滤,实测: " + sql);
+        assertTrue(sql.contains("notify_count < #{"),
+                "必须按 notify_count 严格小于过滤(达上限的单不再推),实测: " + sql);
+        assertTrue(sql.contains("ORDER BY last_notify_time ASC"),
+                "饿最久的先推,实测: " + sql);
+        assertTrue(sql.contains("LIMIT 50"),
+                "必须带 LIMIT,实测: " + sql);
+    }
+
+    /** 扫出来的每一单都要真的再推一次(扫描不是只记日志) */
+    @Test
+    void retryFailed_dispatchesPushForEachScannedOrder() {
+        HarOrderEntity first = order("K1");
+        HarOrderEntity second = order("K1");
+        second.setOrderNo("HAR2");
+        when(orderMapper.selectList(any())).thenReturn(List.of(first, second));
+        stubKeyWithUrl("https://merchant.example.com/cb");
+        stubPushOk();
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.retryFailed();
+
+        verify(restTemplate, timeout(3000).times(2))
+                .postForEntity(eq("https://merchant.example.com/cb"), any(HttpEntity.class), eq(String.class));
+        verify(orderMapper, timeout(3000).times(2)).update(isNull(), any());
+    }
+
+    /**
+     * 重试时**仍然**没有地址 ⇒ 再记一次 FAILED(计数 +1)。
+     *
+     * 这次尝试也要计数:否则「一直没配地址」的单会被每轮调度无限重扫(上限永远够不着)。
+     */
+    @Test
+    void retryFailed_scannedOrderWithoutUrl_recordsAnotherFailedAttempt() {
+        when(orderMapper.selectList(any())).thenReturn(List.of(order(null)));
+        when(orderMapper.update(isNull(), any())).thenReturn(1);
+
+        service.retryFailed();
+
+        verify(restTemplate, never()).postForEntity(anyString(), any(), eq(String.class));
+        ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper).update(isNull(), w.capture());
+        assertEquals(HarNotifyStatus.FAILED, setValueOf(w.getValue(), "notify_status"),
+                "实测 SET: " + w.getValue().getSqlSet());
+    }
+
+    /** 扫描本身炸了(库抖动)不能把异常抛给调度线程 —— 那上面还挂着轮询主任务 */
+    @Test
+    void retryFailed_scanThrows_doesNotPropagate() {
+        when(orderMapper.selectList(any())).thenThrow(new RuntimeException("db down"));
+
+        assertDoesNotThrow(() -> service.retryFailed());
+        verify(orderMapper).selectList(any());
+    }
+
+    // ─────────────────────────── 交付脚本的 schema 前提 ───────────────────────────
+
+    /**
+     * 重试腿的两列只存在于**交付给用户的建表脚本**里(本工程未接 Flyway,脚本由用户
+     * 手工执行)—— 代码这边只有实体映射与查询。脚本缺一列 ⇒ 每次回调尝试的 UPDATE
+     * 都抛「column does not exist」,被 {@code writeNotifyResult} 的 catch 吞成一行
+     * warn(表现:回调推了但状态永远 PENDING),且那段窗口事后补跑脚本也救不回来
+     * (PENDING 不在重试扫描的捞取范围内)。所以这里不读代码,直接读那份脚本。
+     *
+     * <p>封顶那句(存量不补推)是**用户裁定**的落地处:执行那一刻已有的 FAILED 单
+     * 不能因为新列默认 0 被当成「没试满」全部补推。删掉它、或把上限值改得与
+     * {@code har.callback-max-retries} 不一致(默认 8,生产配得比 8 大时脚本要同步改),
+     * 部署时就会把存量积压重推一轮 —— 而套件除了本用例没有任何红点。
+     */
+    @Test
+    void schemaScript_addsRetryColumnsAndSealsExistingFailedRows() throws Exception {
+        Path sql = Path.of("src/main/resources/db/har-callback-retry.sql");
+        assertTrue(Files.exists(sql), "找不到建表脚本(本用例读的是相对 java/ 的仓库路径): " + sql.toAbsolutePath());
+        String text = new String(Files.readAllBytes(sql), StandardCharsets.UTF_8);
+
+        assertTrue(text.matches("(?s).*ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS notify_count int NOT NULL DEFAULT 0.*"),
+                "notify_count 列不见了 —— 每次回调尝试都写它(SQL 侧自增),缺列即每次写库抛错");
+        assertTrue(text.matches("(?s).*ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS last_notify_time timestamp.*"),
+                "last_notify_time 列不见了 —— 重试扫描的排序键,缺列即重试扫描每分钟抛一次错");
+        assertTrue(text.matches("(?s).*UPDATE pay_har_order SET notify_count = 8 WHERE notify_status = 'FAILED' AND notify_count = 0.*"),
+                "存量 FAILED 行的封顶句不见了/被改了 —— 「存量不补推」是 2026-10-09 的用户裁定:"
+                        + "新列默认 0 会让存量失败单立刻被当成「没试满」全部重推。值必须 = har.callback-max-retries(默认 8)");
+        assertTrue(text.matches("(?s).*CREATE INDEX IF NOT EXISTS idx_har_order_callback_retry\\s+ON pay_har_order \\(last_notify_time\\) WHERE notify_status = 'FAILED'.*"),
+                "重试扫描的部分索引不见了 —— 该查询唯一的热路径(谓词 + 排序键一个索引全包)");
+    }
+
+    /**
+     * 取 SET 子句里某一列**绑定**的值 —— 只看 SET,不看 WHERE。
+     *
+     * 为什么不直接倒 paramNameValuePairs 全集:那张表 SET 与 WHERE 共用,
+     * contains 可能被另一段满足(同 HarOrderServiceTest#setValueOf 的理由)。
+     */
+    private static Object setValueOf(UpdateWrapper<?> w, String column) {
+        for (String item : w.getSqlSet().split(",")) {
+            if (!item.startsWith(column + "=")) {
+                continue;
+            }
+            Matcher m = Pattern.compile("paramNameValuePairs\\.(\\w+)").matcher(item);
+            assertTrue(m.find(), "这一列没有绑定值,形状不认识: " + item);
+            return w.getParamNameValuePairs().get(m.group(1));
+        }
+        fail("SET 子句里没有这一列: " + column + ",实测 SET: " + w.getSqlSet());
+        return null;
+    }
+
+    /** 只取 WHERE 段引用到的绑定值,不倒参数表全集 —— 后者会把 SET 的绑定值也算进来 */
+    private static Set<Object> whereBoundValues(UpdateWrapper<?> w) {
+        Matcher m = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)")
+                .matcher(w.getSqlSegment());
+        Set<Object> out = new HashSet<>();
+        while (m.find()) {
+            out.add(w.getParamNameValuePairs().get(m.group(1)));
+        }
+        return out;
+    }
+}

+ 7 - 7
java/src/test/java/com/payment/platform/module/payment/har/service/HarCheckoutLinkServiceTest.java

@@ -38,14 +38,14 @@ import static org.mockito.Mockito.*;
  *
  * <p>形态逐条对标 {@code LsyCheckoutLinkServiceTest},差异只有两处**通道事实**:
  * 定位列是 {@code merchant_code}(HAR 没有 mch_no),以及路径前缀带
- * {@code /har/} 段({@code /#/checkout/har/{token}},与乐收赢那条并存不冲突)。
+ * {@code /alipay/} 段({@code /#/checkout/alipay/{token}},与乐收赢那条并存不冲突)。
  *
  * <p>本类钉的四件事都是**会静默出错**的那一类:
  * <ol>
  *   <li><b>重复点按钮不能换 token</b> —— 链接是印出去/贴出去的东西,换一次等于让客户手上那张失效;</li>
  *   <li><b>停用的链接必须 404</b> —— {@code status} 列如果没人读,它就是本仓警告过的死字段;</li>
  *   <li><b>拼出来的 URL 形态</b> —— 顾客侧是 hash 路由({@code createWebHashHistory}),
- *       写成 {@code /checkout/har/{token}} 会命中静态路由表末尾的通配 404;</li>
+ *       写成 {@code /checkout/alipay/{token}} 会命中静态路由表末尾的通配 404;</li>
  *   <li><b>安全论证依赖的 schema 事实写在脚本里</b> —— 免登录按 token 定位之所以合规,
  *       唯一依据是「token 有全局唯一索引」(通则 (甲)·预防)。索引只写在 SQL 文件里,
  *       所以这里直接读那个文件钉住它。</li>
@@ -281,8 +281,8 @@ class HarCheckoutLinkServiceTest {
 
         String url = service.buildUrl("tok", req);
 
-        assertEquals("https://qcsj88888.com/#/checkout/har/tok", url,
-                "顾客侧是 hash 路由:路径必须是 /#/checkout/har/{token},写 /checkout/har/{token} 会 404");
+        assertEquals("https://qcsj88888.com/#/checkout/alipay/tok", url,
+                "顾客侧是 hash 路由:路径必须是 /#/checkout/alipay/{token},写 /checkout/alipay/{token} 会 404");
     }
 
     @Test
@@ -295,7 +295,7 @@ class HarCheckoutLinkServiceTest {
         req.addHeader("X-Forwarded-Proto", "https");
         req.addHeader("X-Forwarded-Host", "pay.example.com");
 
-        assertEquals("https://pay.example.com/#/checkout/har/tok", service.buildUrl("tok", req));
+        assertEquals("https://pay.example.com/#/checkout/alipay/tok", service.buildUrl("tok", req));
     }
 
     @Test
@@ -306,7 +306,7 @@ class HarCheckoutLinkServiceTest {
         req.setServerName("192.168.1.9");
         req.setServerPort(8080);
 
-        assertEquals("http://192.168.1.9:8080/#/checkout/har/tok", service.buildUrl("tok", req));
+        assertEquals("http://192.168.1.9:8080/#/checkout/alipay/tok", service.buildUrl("tok", req));
     }
 
     @Test
@@ -316,7 +316,7 @@ class HarCheckoutLinkServiceTest {
         req.setServerName("ignored");
         req.setScheme("http");
 
-        assertEquals("https://pay.example.com/#/checkout/har/tok", service.buildUrl("tok", req),
+        assertEquals("https://pay.example.com/#/checkout/alipay/tok", service.buildUrl("tok", req),
                 "yml 里配了 har.checkout-base-url 就以它为准(反代只转发路径、Host 是内网名时用得上)");
     }
 

+ 28 - 3
java/src/test/java/com/payment/platform/module/payment/har/service/HarIpRegionResolverTest.java

@@ -20,9 +20,10 @@ 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 四个嵌套对象原样略去,
- * 其余逐字未改);末一条是构造输入,已标注。用真实回包是因为这个解析器的全部工作
+ * <p>下面 6 段 JSON 是 2026-10-08 用 curl 从 {@code https://ipwho.is/{ip}?lang=zh-CN}
+ * 抄回来的**真实回包**(末尾的 flag/connection/timezone/readme 四个嵌套对象与 {@code borders}
+ * 字段略去 —— 2026-10-08 逐字段比对过线上回包,此处据实写成「也略去」;其余逐字未改);
+ * 末一条是构造输入,已标注。用真实回包是因为这个解析器的全部工作
  * 就是「上游到底怎么回」,自造的理想 JSON 会把真实坑(繁体「區」、区级 city 名)滤掉。
  */
 class HarIpRegionResolverTest {
@@ -34,6 +35,13 @@ class HarIpRegionResolverTest {
              "latitude":32.0616657,"longitude":118.7777759,"is_eu":false,"postal":"210000","calling_code":"86",
              "capital":"北京"}""";
 
+    /** 实测:118.248.202.107 → 湖南省 / **长沙**(用户 2026-10-08 从 ipwho.is 取回的同一个 IP) */
+    private static final String P_HN_CHANGSHA = """
+            {"ip":"118.248.202.107","success":true,"type":"IPv4","continent":"亚洲","continent_code":"AS",
+             "country":"中国","country_code":"CN","region":"湖南省","region_code":"HN","city":"长沙",
+             "latitude":28.1987397,"longitude":112.9708739,"is_eu":false,"postal":"410000","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",
@@ -98,6 +106,23 @@ class HarIpRegionResolverTest {
         assertEquals("南京", geo.cityName());
     }
 
+    /**
+     * 湖南省这条回包的 {@code city} 是「长沙」——**不带「市」后缀**(与「南京」「杭州」同形)。
+     *
+     * <p>钉它的理由:省名带后缀、市名不带,是上游两种不同的习惯;解析器这一层**不做任何规整**,
+     * 归一化在 {@code HarRegionCatalog}(那边有「长沙 → 4301」的用例)。这条一旦被人顺手 trim 或
+     * 补后缀,两边就有一处会红。
+     */
+    @Test
+    void lookup_parsesRealChangshaPayload() {
+        IpGeo geo = resolverReturning(P_HN_CHANGSHA).lookup("118.248.202.107").orElse(null);
+
+        assertEquals("CN", geo.countryCode());
+        assertEquals("湖南省", geo.regionName());
+        assertEquals("HN", geo.regionCode());
+        assertEquals("长沙", geo.cityName());
+    }
+
     /** ★ {@code ?lang=zh-CN} 是这套设计的前提:不带它上游回拼音/英文,按中文名匹配全线失效。 */
     @Test
     void lookup_asksForChineseNamesExplicitly() {

+ 432 - 2
java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

@@ -19,6 +19,7 @@ import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderVO;
 import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
 import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.entity.HarShopEntity;
 import com.payment.platform.module.payment.har.enums.HarOrderStatus;
 import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
 import com.payment.platform.module.payment.stream.OrderEventPublisher;
@@ -32,7 +33,12 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
 import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -58,14 +64,16 @@ class HarOrderServiceTest {
 
     @Mock private HarOrderMapper orderMapper;
     @Mock private HarMerchantService merchantService;
+    @Mock private HarShopService shopService;
     @Mock private HarClient harClient;
     @Mock private OrderEventPublisher publisher;
+    @Mock private HarCallbackService callbackService;
 
     private HarOrderService service;
 
     @BeforeEach
     void setUp() {
-        service = new HarOrderService(orderMapper, merchantService, harClient, publisher);
+        service = new HarOrderService(orderMapper, merchantService, shopService, harClient, publisher, callbackService);
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
         // (否则 getSqlSegment() 抛 MybatisPlusException: can not find lambda cache for this entity。
         //   同款引导见 LsyCallbackServiceTest / AlipayBatchPayServiceTest。)
@@ -175,7 +183,7 @@ class HarOrderServiceTest {
         assertEquals(vo.getOrderNo(), captor.getValue().getThirdOrderNo());
         assertNotEquals("BIZ-0001", captor.getValue().getThirdOrderNo());
         assertEquals("BIZ-0001", vo.getOutBizNo());
-        assertTrue(vo.getOrderNo().startsWith("HAR"));
+        assertTrue(vo.getOrderNo().startsWith("AP"));
     }
 
     /** 下单时排入轮询:上游通知窗口只有约 25 秒,超时后只能靠主动查询收敛 */
@@ -258,6 +266,141 @@ class HarOrderServiceTest {
         assertEquals("har_ali_un_stage", req.getValue().getBiz());
     }
 
+    /**
+     * 订单号前缀是**中性**的 {@code AP},不带上游品牌(2026-10-09 去上游化)。
+     *
+     * <p>order_no 出现在开放 API 响应、顾客收款页和上游的 third_order_no 里 ——
+     * 是调用方每天都看得到的字符串,HAR 前缀等于把上游名字印在上面。
+     * 存量单不动(改不了也不该改),这里只钉新生成的那一串。
+     */
+    @Test
+    void create_orderNoUsesNeutralPrefix_notUpstreamBrand() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto(), "E1", "KEY1");
+
+        ArgumentCaptor<HarOrderEntity> captor = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(captor.capture());
+        String orderNo = captor.getValue().getOrderNo();
+        assertTrue(orderNo.startsWith("AP"), "订单号前缀应为中性词 AP。实测: " + orderNo);
+        assertFalse(orderNo.startsWith("HAR"), "订单号不得带上游品牌。实测: " + orderNo);
+    }
+
+    /**
+     * {@code biz} 留空 + 非分期 ⇒ 平台按 trade_type 派生上游要的取值(2026-10-09 去上游化:
+     * 这串带上游品牌,从调用方契约里拿掉,文档改为「留空即可」)。
+     * 落库与发给上游必须是**同一份** —— 两份不同的话对账时对不上。
+     */
+    @Test
+    void create_bizBlank_nonStage_derivesTheUpstreamValue() {
+        HarOrderCreateDTO dto = dto(); // dto() 的 trade_type 已是 alipay_un_stage,biz 未设
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertEquals("har_ali_un_stage", req.getValue().getBiz(),
+                "调用方留空时由平台派生 —— 这串不再要求调用方知道");
+
+        ArgumentCaptor<HarOrderEntity> entity = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(entity.capture());
+        assertEquals("har_ali_un_stage", entity.getValue().getBiz(),
+                "落库的与发给上游的必须是同一份");
+    }
+
+    /** 留空 + 分期 ⇒ 保持空:分期没有对应的规范取值,不替上游编一个。 */
+    @Test
+    void create_bizBlank_stageTradeType_leavesItBlank() {
+        HarOrderCreateDTO dto = dto();
+        dto.setTradeType("alipay_stage");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertNull(req.getValue().getBiz(), "分期没有派生依据,保持空而不是编一个");
+    }
+
+    /**
+     * 显式给了 biz ⇒ 原样透传、派生不覆盖(后台表单与既有调用方那条路)。
+     *
+     * <p>夹具特意用分期 + 一个与派生值**不同**的串:若实现变成「无条件派生」,
+     * 这条立刻红 —— 拿非分期 + 派生值同串作夹具的话,两种实现在改前改后都绿。
+     */
+    @Test
+    void create_bizExplicit_passesThroughEvenOnStage() {
+        HarOrderCreateDTO dto = dto();
+        dto.setTradeType("alipay_stage");
+        dto.setBiz("caller_knows_best");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertEquals("caller_knows_best", req.getValue().getBiz(),
+                "显式传了就不许被派生覆盖");
+    }
+
+    /**
+     * {@code trade_type} 留空 ⇒ 平台按默认值 {@code alipay_un_stage} 填充(2026-10-09:
+     * 这串从调用方契约里拿掉,开放文档不再列它)。
+     *
+     * <p>填充必须同时落在**落库**与**发给上游**两处,且要早于 {@code biz} 的派生 ——
+     * 派生读的就是这个取值:空值会让非分期单派生出 null,上游收到一笔没有 biz 的交易。
+     */
+    @Test
+    void create_tradeTypeBlank_defaultsToAlipayUnStage() {
+        HarOrderCreateDTO dto = dto();
+        dto.setTradeType(null);
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarOrderEntity> entity = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(entity.capture());
+        assertEquals("alipay_un_stage", entity.getValue().getTradeType(),
+                "落库的必须是填充后的默认值");
+
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertEquals("alipay_un_stage", req.getValue().getTradeType(),
+                "发上游的与落库的必须是同一份");
+        assertEquals("har_ali_un_stage", req.getValue().getBiz(),
+                "默认值要参与 biz 派生 —— 留空不该让 biz 跟着变空");
+        assertEquals("har_ali_un_stage", entity.getValue().getBiz());
+    }
+
+    /**
+     * 被扫(pay)那条腿同样填默认值,且**空串与 null 同口径**。
+     *
+     * <p>两条腿各建各的请求({@code buildPrecreateRequest} / {@code buildPayRequest}),
+     * 只补一条等于漏了一条;而空串是真实存在的入参形态(调用方表单留空会发 {@code ""}),
+     * 只判 null 的实现会把它原样送给上游。
+     */
+    @Test
+    void create_tradeTypeEmptyString_scanMode_defaultsInPayRequest() {
+        HarOrderCreateDTO dto = dto();
+        dto.setTradeType("");
+        dto.setAuthCode("280812345678901234");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.pay(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarPayRequest> req = ArgumentCaptor.forClass(HarPayRequest.class);
+        verify(harClient).pay(req.capture());
+        assertEquals("alipay_un_stage", req.getValue().getTradeType(),
+                "空串按留空处理,不许原样送给上游");
+    }
+
     /**
      * 上游**明确拒绝**(参数错误之类)⇒ 本地这一行必须跟着收尾。
      *
@@ -327,6 +470,159 @@ class HarOrderServiceTest {
         verify(harClient, never()).precreate(any());
     }
 
+    // ==================== 下单:按顾客归属挑店(开放 API:IP 优先、省码回退,2026-10-09) ====================
+
+    /**
+     * 传了 IP/省码、没传任何门店码 ⇒ 平台替调用方挑店;挑中的店要**同时**进本地记录
+     * 与上游请求 —— 只进一处都是半截:本地有、上游没有 = 风控不认;上游有、本地没有 =
+     * 页面上看不出这单挂的哪家店。
+     *
+     * <p>精确参数 = 同时钉住「第三个位置传的是 ip、种子是 out_biz_no」——两个参数
+     * 任一被换错,这条会因参数不匹配拿到 null 而红。
+     */
+    @Test
+    void create_withIpAndProvinceCode_passesBothToThePick() {
+        HarOrderCreateDTO dto = dto();
+        dto.setProvinceCode("320000");
+        dto.setIp("114.114.114.114");
+        HarShopEntity picked = new HarShopEntity();
+        picked.setThirdShopId("NJ");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopService.pickForOrder("M001", "320000", "114.114.114.114", "BIZ-0001")).thenReturn(picked);
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarOrderEntity> entity = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(entity.capture());
+        assertEquals("NJ", entity.getValue().getThirdShopCode(), "挑中的店要进本地记录");
+        assertNull(entity.getValue().getShopCode(), "只填 third_shop_code(与收款链接腿一致),不编造平台门店码");
+
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertEquals("NJ", req.getValue().getThirdShopCode(), "挑中的店必须真的发到上游");
+    }
+
+    /** 被扫腿同款接线:builder 改了一个、漏了另一个的话,这条红而上面那条绿 */
+    @Test
+    void create_scanMode_alsoSendsThePickedShop() {
+        HarOrderCreateDTO dto = dto();
+        dto.setAuthCode("280812345678901234");
+        dto.setProvinceCode("320000");
+        HarShopEntity picked = new HarShopEntity();
+        picked.setThirdShopId("NJ");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopService.pickForOrder("M001", "320000", null, "BIZ-0001")).thenReturn(picked);
+        when(harClient.pay(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        ArgumentCaptor<HarPayRequest> req = ArgumentCaptor.forClass(HarPayRequest.class);
+        verify(harClient).pay(req.capture());
+        assertEquals("NJ", req.getValue().getThirdShopCode(), "挑中的店必须真的发到上游");
+    }
+
+    /** 显式门店码优先:给了 third_shop_code 就不再挑(挑出来的会覆盖调用方明确指定的店) */
+    @Test
+    void create_explicitThirdShopCode_beatsRegionCodes() {
+        HarOrderCreateDTO dto = dto();
+        dto.setThirdShopCode("EXPLICIT");
+        dto.setProvinceCode("320000");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        verifyNoInteractions(shopService);
+        ArgumentCaptor<HarOrderEntity> entity = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(entity.capture());
+        assertEquals("EXPLICIT", entity.getValue().getThirdShopCode());
+        ArgumentCaptor<HarPrecreateRequest> req = ArgumentCaptor.forClass(HarPrecreateRequest.class);
+        verify(harClient).precreate(req.capture());
+        assertEquals("EXPLICIT", req.getValue().getThirdShopCode(), "上游发的必须是显式指定的那个,不是挑的");
+    }
+
+    /** 显式门店码优先:给了 shop_code 同样不挑 */
+    @Test
+    void create_explicitShopCode_beatsRegionCodes() {
+        HarOrderCreateDTO dto = dto();
+        dto.setShopCode("S-PLATFORM");
+        dto.setProvinceCode("320000");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto, "E1", "KEY1");
+
+        verifyNoInteractions(shopService);
+        ArgumentCaptor<HarOrderEntity> entity = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).insert(entity.capture());
+        assertEquals("S-PLATFORM", entity.getValue().getShopCode());
+        assertNull(entity.getValue().getThirdShopCode(), "没挑店就不该凭空多出一个 third_shop_code");
+    }
+
+    /**
+     * 挑不出来 ⇒ 400,且**不留本地单、不打上游、不发订单信号** —— 与收款链接腿
+     * 「挑不出来就拒收」同一口径。静默退回「不带门店下单」等于调用方明确要求的
+     * 地区归属被悄悄丢掉。
+     */
+    @Test
+    void create_regionPickRejected_400_beforePersistAndUpstream() {
+        HarOrderCreateDTO dto = dto();
+        dto.setProvinceCode("320000");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopService.pickForOrder("M001", "320000", null, "BIZ-0001"))
+                .thenThrow(new BusinessException(400, "该地区没有可用门店: 省 320000"));
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.create(dto, "E1", "KEY1"));
+
+        assertEquals(400, e.getCode());
+        verify(orderMapper, never()).insert(any());
+        verify(harClient, never()).precreate(any());
+        verify(publisher, never()).changed(any(), any());
+    }
+
+    /** 什么都不传 = 现状:一个字段都不碰,不去问挑店 */
+    @Test
+    void create_noRegionFields_neverConsultsShopService() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto(), "E1", "KEY1");
+
+        verifyNoInteractions(shopService);
+    }
+
+    /** 只给 ip 也是「地区意图」——进入挑店并吃它那句 400,而不是被静默忽略 */
+    @Test
+    void create_ipOnly_entersPickAndPropagates400() {
+        HarOrderCreateDTO dto = dto();
+        dto.setIp("1.2.3.4");
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(shopService.pickForOrder("M001", null, "1.2.3.4", "BIZ-0001"))
+                .thenThrow(new BusinessException(400, "用户 IP 无法解析出归属地: 1.2.3.4"));
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.create(dto, "E1", "KEY1"));
+
+        assertEquals(400, e.getCode());
+        verify(orderMapper, never()).insert(any());
+    }
+
+    // ==================== 商家回调的触发面(2026-10-09 补齐轮询腿) ====================
+    //
+    // 上游通知窗口只有约 25 秒,丢了的通知全靠轮询收敛 —— 收敛出真流转就必须补一次
+    // 商家回调,否则商户永远收不到这一笔。两个闸门:写库成功(rows>0)+ 真流转。
+
+    /** 下单(0 等待支付)不推:调用方同步已拿到响应,后续流转由通知/轮询送 */
+    @Test
+    void create_doesNotNotifyEnterprise() {
+        when(merchantService.requireByEnterpriseId("E1")).thenReturn(merchant());
+        when(harClient.precreate(any())).thenReturn(response("10000", precreateDetail()));
+
+        service.create(dto(), "E1", "KEY1");
+
+        verifyNoInteractions(callbackService);
+    }
+
     // ==================== 主动查询 ====================
 
     /**
@@ -398,6 +694,93 @@ class HarOrderServiceTest {
         assertNotNull(o.getNextQueryTime());
     }
 
+    /** 状态真变了且写库成功 ⇒ 回调一次,且必须晚于写库(回调报文取写库后的这一行) */
+    @Test
+    void sync_statusTransition_notifiesEnterpriseAfterPersisting() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("10000", "{\"status\":1,\"order_no\":\"UP-1\"}"));
+        when(orderMapper.updateById(any())).thenReturn(1);
+
+        service.syncFromUpstream(o);
+
+        InOrder inOrder = inOrder(orderMapper, callbackService);
+        inOrder.verify(orderMapper).updateById(any());
+        inOrder.verify(callbackService).notifyEnterprise(o);
+        assertEquals(HarOrderStatus.PAID, o.getTradeStatus(), "回调报文取的是写库后的这一行");
+    }
+
+    /**
+     * 状态没变(0→0 的常规轮询)⇒ 不回调。
+     *
+     * 每轮都推一帧会把商户的回调面变成噪音。publisher 那条 verify 是正对照:
+     * 这一支照常发订单信号(页面上仍会刷新),只是不碰商家回调。
+     */
+    @Test
+    void sync_noTransition_doesNotNotifyEnterprise() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("10000", "{\"status\":0}"));
+        when(orderMapper.updateById(any())).thenReturn(1);
+
+        service.syncFromUpstream(o);
+
+        verify(publisher).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
+        verifyNoInteractions(callbackService);
+    }
+
+    /**
+     * updateById 被乐观锁挡掉(返回 0 行)⇒ 不许回调。
+     *
+     * 这一刻内存里的 order 已经是 PAID,但库里没有这一行 —— 回调出去就是把一个
+     * 没写进库的状态宣称给商户。下一轮轮询会重新检出流转并把这次补上。
+     */
+    @Test
+    void sync_updateByIdMissed_doesNotNotifyEnterprise() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(harClient.query(any())).thenReturn(response("10000", "{\"status\":1}"));
+        when(orderMapper.updateById(any())).thenReturn(0);
+
+        service.syncFromUpstream(o);
+
+        verifyNoInteractions(callbackService);
+    }
+
+    /** 状态没变但退款时间第一次出现(2 已退款 + refund_time 从无到有)也是一次流转 */
+    @Test
+    void sync_refundTimeNewlyKnown_notifiesEvenWhenStatusUnchanged() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.REFUNDED);
+        when(harClient.query(any())).thenReturn(response("10000",
+                "{\"status\":2,\"refund_time\":\"2026-10-09 12:00:00\"}"));
+        when(orderMapper.updateById(any())).thenReturn(1);
+
+        service.syncFromUpstream(o);
+
+        assertNotNull(o.getRefundTime());
+        verify(callbackService).notifyEnterprise(o);
+    }
+
+    /**
+     * 同一时刻的不同时区表示**不算**流转(按 Instant 比较)。
+     *
+     * 库读回的 refund_time 可能带别的偏移(pgjdbc 读 timestamp 挂 UTC 的历史事故),
+     * 而新解析的是系统时区表示 ⇒ 若按 OffsetDateTime.equals 比,退款成功后每一轮
+     * 轮询都会判成「变了」并重发回调。这里用 withOffsetSameInstant 造出同刻异表示。
+     */
+    @Test
+    void sync_refundTimeSameInstantDifferentOffset_isNotATransition() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.REFUNDED);
+        o.setRefundTime(LocalDateTime.of(2026, 10, 9, 12, 0)
+                .atZone(ZoneId.systemDefault()).toOffsetDateTime()
+                .withOffsetSameInstant(ZoneOffset.UTC));
+        when(harClient.query(any())).thenReturn(response("10000",
+                "{\"status\":2,\"refund_time\":\"2026-10-09 12:00:00\"}"));
+        when(orderMapper.updateById(any())).thenReturn(1);
+
+        service.syncFromUpstream(o);
+
+        assertNotNull(o.getRefundTime());
+        verifyNoInteractions(callbackService);
+    }
+
     // ==================== 轮询排期 ====================
 
     @Test
@@ -1000,6 +1383,53 @@ class HarOrderServiceTest {
         verify(publisher, times(2)).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
     }
 
+    /** 受理(1 已支付 → 4 退款中)就是一次真实流转 ⇒ 商户要立刻收到 4 那一帧 */
+    @Test
+    void openRefund_preemptHit_notifiesEnterpriseWithRefundingFrame() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("10000", null));
+        List<String> statusAtNotify = new ArrayList<>();
+        doAnswer(inv -> {
+            statusAtNotify.add(((HarOrderEntity) inv.getArgument(0)).getTradeStatus());
+            return null;
+        }).when(callbackService).notifyEnterprise(any());
+
+        service.openRefund(refundDto());
+
+        assertEquals(List.of(HarOrderStatus.REFUNDING), statusAtNotify,
+                "等上游回话期间,商户侧就该是「退款中」");
+    }
+
+    /** 抢占没命中(一个字节都没改)⇒ 不回调(同 publisher 的负例纪律) */
+    @Test
+    void openRefund_preemptMissed_doesNotNotifyEnterprise() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(0);
+
+        assertThrows(BusinessException.class, () -> service.openRefund(refundDto()));
+
+        verifyNoInteractions(callbackService);
+    }
+
+    /** 上游拒绝 ⇒ 先 4 后 1 两帧都要送:缺了回退帧,商户会一直以为这笔在退款中 */
+    @Test
+    void openRefund_upstreamRejected_notifiesBothRefundingAndRollbackFrames() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("40004", null));
+        List<String> statusAtNotify = new ArrayList<>();
+        doAnswer(inv -> {
+            statusAtNotify.add(((HarOrderEntity) inv.getArgument(0)).getTradeStatus());
+            return null;
+        }).when(callbackService).notifyEnterprise(any());
+
+        assertThrows(BusinessException.class, () -> service.openRefund(refundDto()));
+
+        assertEquals(List.of(HarOrderStatus.REFUNDING, HarOrderStatus.PAID), statusAtNotify,
+                "顺序=先受理后回退;缺一帧商户侧就停在错的状态上");
+    }
+
     /** 优先用上游单号退款;拿不到时走 third_order_no(同查询口径) */
     @Test
     void openRefund_prefersUpstreamOrderNo() {

+ 234 - 40
java/src/test/java/com/payment/platform/module/payment/har/service/HarShopRegionPickTest.java

@@ -3,6 +3,7 @@ package com.payment.platform.module.payment.har.service;
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.har.client.HarClient;
 import com.payment.platform.module.payment.har.entity.HarShopEntity;
 import com.payment.platform.module.payment.har.enums.HarShopCreateStatus;
@@ -19,11 +20,13 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.verify;
@@ -42,12 +45,13 @@ class HarShopRegionPickTest {
     @Mock private HarShopMapper shopMapper;
     @Mock private HarMerchantService merchantService;
     @Mock private HarClient harClient;
+    @Mock private HarIpRegionResolver ipRegionResolver;
 
     private HarShopService service;
 
     @BeforeEach
     void setUp() {
-        service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog());
+        service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog(), ipRegionResolver);
         // 同 HarShopServiceTest:让 LambdaQueryWrapper.getSqlSegment() 能在无 Spring 上下文时工作
         MybatisConfiguration configuration = new MybatisConfiguration();
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarShopEntity.class);
@@ -78,21 +82,21 @@ class HarShopRegionPickTest {
     @Test
     void pick_cityTierWins() {
         List<HarShopEntity> candidates = List.of(
-                ok("NJ", "32", "3201"),
-                ok("SZ", "32", "3205"));
+                ok("NJ", "320000", "320100"),
+                ok("SZ", "320000", "320500"));
 
-        assertEquals("NJ", pick(candidates, "32", "3201", "114.114.114.114").getThirdShopId());
+        assertEquals("NJ", pick(candidates, "320000", "320100", "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"));
+                ok("SZ", "320000", "320500"),
+                ok("WX", "320000", "320200"),
+                ok("HZ", "330000", "330100"));
 
-        HarShopEntity picked = pick(candidates, "32", "3201", "114.114.114.114");
+        HarShopEntity picked = pick(candidates, "320000", "320100", "114.114.114.114");
 
         assertNotNull(picked);
         assertTrue(Set.of("SZ", "WX").contains(picked.getThirdShopId()),
@@ -105,27 +109,27 @@ class HarShopRegionPickTest {
      */
     @Test
     void pick_cityCodeUnderAnotherProvinceIsNotTrusted() {
-        List<HarShopEntity> candidates = List.of(ok("X", "99", "3201"));
+        List<HarShopEntity> candidates = List.of(ok("X", "999999", "320100"));
 
-        assertNull(pick(candidates, "32", "3201", "114.114.114.114"));
+        assertNull(pick(candidates, "320000", "320100", "114.114.114.114"));
     }
 
     @Test
     void pick_blankProvinceCode_picksNothing() {
-        List<HarShopEntity> candidates = List.of(ok("NJ", "32", "3201"));
+        List<HarShopEntity> candidates = List.of(ok("NJ", "320000", "320100"));
 
-        assertNull(pick(candidates, null, "3201", "114.114.114.114"));
-        assertNull(pick(candidates, "  ", "3201", "114.114.114.114"));
+        assertNull(pick(candidates, null, "320100", "114.114.114.114"));
+        assertNull(pick(candidates, "  ", "320100", "114.114.114.114"));
         // 正控:同一个候选集,省码给对就必须挑得出来(否则上两条是量具坏了在假绿)
-        assertNotNull(pick(candidates, "32", "3201", "114.114.114.114"));
+        assertNotNull(pick(candidates, "320000", "320100", "114.114.114.114"));
     }
 
     /** 空 city 只走省级(ipwho.is 偶尔只给到省) */
     @Test
     void pick_blankCityCode_stillUsesTheProvinceTier() {
-        List<HarShopEntity> candidates = List.of(ok("NJ", "32", "3201"));
+        List<HarShopEntity> candidates = List.of(ok("NJ", "320000", "320100"));
 
-        assertEquals("NJ", pick(candidates, "32", null, "114.114.114.114").getThirdShopId());
+        assertEquals("NJ", pick(candidates, "320000", null, "114.114.114.114").getThirdShopId());
     }
 
     // ──────────────────────────── 什么算「能用的店」 ────────────────────────────
@@ -138,31 +142,31 @@ class HarShopRegionPickTest {
     @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"), "未建成的店不能拿去下单");
+                shop("FAILED", "320000", "320100", HarShopCreateStatus.FAILED, "S1"),
+                shop("PENDING", "320000", "320100", HarShopCreateStatus.PENDING, "S2"),
+                shop("NOID", "320000", "320100", HarShopCreateStatus.SUCCESS, null),
+                shop("BLANKID", "320000", "320100", HarShopCreateStatus.SUCCESS, "  "),
+                shop("NOSTATUS", "320000", "320100", null, "S5"));
+        assertNull(pick(unusable, "320000", "320100", "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());
+        withOneGood.add(ok("GOOD", "320000", "320100"));
+        assertEquals("GOOD", pick(withOneGood, "320000", "320100", "114.114.114.114").getThirdShopId());
     }
 
     @Test
     void pick_noCandidatesAtAll_picksNothing() {
-        assertNull(pick(List.of(), "32", "3201", "114.114.114.114"));
+        assertNull(pick(List.of(), "320000", "320100", "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"));
+                ok("A", "320000", "320100"),
+                ok("B", "320000", "320100"),
+                ok("C", "320000", "320100"),
+                ok("D", "320000", "320100"));
     }
 
     /** 同一个顾客(同 IP)在缓存过期后重来一次,必须还是那一家 */
@@ -172,8 +176,8 @@ class HarShopRegionPickTest {
 
         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(),
+            assertEquals(pick(candidates, "320000", "320100", seed).getThirdShopId(),
+                    pick(candidates, "320000", "320100", seed).getThirdShopId(),
                     "同一 IP 必须稳定落到同一家,seed=" + seed);
         }
     }
@@ -185,7 +189,7 @@ class HarShopRegionPickTest {
         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());
+            chosen.add(pick(candidates, "320000", "320100", "10.0." + (i / 250) + "." + (i % 250)).getThirdShopId());
         }
 
         assertEquals(Set.of("A", "B", "C", "D"), chosen, "400 个不同 IP 应把所有店都用上");
@@ -196,10 +200,10 @@ class HarShopRegionPickTest {
     void pick_blankSeedIsStillDeterministic() {
         List<HarShopEntity> candidates = fourShopsInJiangsu();
 
-        HarShopEntity first = pick(candidates, "32", "3201", null);
+        HarShopEntity first = pick(candidates, "320000", "320100", null);
         assertNotNull(first);
-        assertEquals(first.getThirdShopId(), pick(candidates, "32", "3201", null).getThirdShopId());
-        assertEquals(first.getThirdShopId(), pick(candidates, "32", "3201", "  ").getThirdShopId());
+        assertEquals(first.getThirdShopId(), pick(candidates, "320000", "320100", null).getThirdShopId());
+        assertEquals(first.getThirdShopId(), pick(candidates, "320000", "320100", "  ").getThirdShopId());
     }
 
     // ──────────────────────────── 查询本身 ────────────────────────────
@@ -215,7 +219,7 @@ class HarShopRegionPickTest {
         ArgumentCaptor<LambdaQueryWrapper<HarShopEntity>> captor =
                 ArgumentCaptor.forClass(LambdaQueryWrapper.class);
 
-        assertNull(service.pickForRegion("M001", "32", "3201", "114.114.114.114"));
+        assertNull(service.pickForRegion("M001", "320000", "320100", "114.114.114.114"));
 
         verify(shopMapper).selectList(captor.capture());
         String sql = captor.getValue().getSqlSegment();
@@ -225,7 +229,7 @@ class HarShopRegionPickTest {
     /** 商户号本身为空就别去查库了(空串会变成 merchant_code = '',查出一片空并静默拒收) */
     @Test
     void pickForRegion_blankMerchantCode_picksNothingWithoutQuerying() {
-        assertNull(service.pickForRegion("  ", "32", "3201", "114.114.114.114"));
+        assertNull(service.pickForRegion("  ", "320000", "320100", "114.114.114.114"));
 
         verify(shopMapper, org.mockito.Mockito.never()).selectList(any());
     }
@@ -234,9 +238,199 @@ class HarShopRegionPickTest {
     @Test
     void pickForRegion_picksFromWhatTheQueryReturned() {
         when(shopMapper.selectList(any())).thenReturn(List.of(
-                ok("NJ", "32", "3201"),
-                ok("HZ", "33", "3301")));
+                ok("NJ", "320000", "320100"),
+                ok("HZ", "330000", "330100")));
 
-        assertEquals("NJ", service.pickForRegion("M001", "32", "3201", "114.114.114.114").getThirdShopId());
+        assertEquals("NJ", service.pickForRegion("M001", "320000", "320100", "114.114.114.114").getThirdShopId());
+    }
+
+    // ──────────────── 下单按顾客归属挑店(开放 API,2026-10-09 改版:IP 优先、省码回退) ────────────────
+
+    /**
+     * 与收款链接腿的差别只在**地区来源**:那边永远是顾客请求 IP 解析出的地名,这边是
+     * 调用方给的**顾客 IP 或 GB/T 2260 省码**。阶梯本身上面各条已钉住,这里钉
+     * 「来源怎么定优先级、挑不出来怎么办」。
+     */
+    private static HarIpRegionResolver.IpGeo ipGeoHunan(String cityName) {
+        // 与真实回包同形:省带「省」、市不带「市」
+        return new HarIpRegionResolver.IpGeo("CN", "湖南省", "HN", cityName);
+    }
+
+    /** IP 能解析出归属地 ⇒ 以它为准,哪怕同时传的 province_code 指向别处(IP 更精确) */
+    @Test
+    void pickForOrder_ipResolves_beatsTheProvinceCode() {
+        when(ipRegionResolver.lookup("114.114.114.114")).thenReturn(Optional.of(ipGeoHunan("长沙")));
+        when(shopMapper.selectList(any())).thenReturn(List.of(
+                ok("NJ", "320000", "320100"),
+                ok("CS", "430000", "430100")));
+
+        assertEquals("CS", service.pickForOrder("M001", "320000", "114.114.114.114", "BIZ-1").getThirdShopId(),
+                "省码是江苏、IP 在湖南 ⇒ 挑湖南的店");
+    }
+
+    /** IP 解析到了省、市名对不上目录(上游常回区级名)⇒ 退到 IP 所在省挑,不报错也不改用省码 */
+    @Test
+    void pickForOrder_ipCityUnknown_fallsBackToTheIpProvince() {
+        when(ipRegionResolver.lookup("114.114.114.114")).thenReturn(Optional.of(ipGeoHunan("渝中區")));
+        when(shopMapper.selectList(any())).thenReturn(List.of(
+                ok("CS", "430000", "430100"),
+                ok("NJ", "320000", "320100")));
+
+        assertEquals("CS", service.pickForOrder("M001", "320000", "114.114.114.114", "BIZ-1").getThirdShopId(),
+                "IP 的省里有店就不该回退到调用方传的省码");
+    }
+
+    /** IP 归属地一家可用门店都没有 ⇒ 回退 province_code 继续挑(2026-10-09 用户裁定) */
+    @Test
+    void pickForOrder_ipRegionHasNoShop_fallsBackToProvinceCode() {
+        when(ipRegionResolver.lookup("114.114.114.114")).thenReturn(Optional.of(ipGeoHunan("长沙")));
+        when(shopMapper.selectList(any())).thenReturn(List.of(ok("NJ", "320000", "320100")));
+
+        assertEquals("NJ", service.pickForOrder("M001", "320000", "114.114.114.114", "BIZ-1").getThirdShopId());
+    }
+
+    /** 挑中的店没有 third_shop_id(脏数据)与「没挑到」同权 ⇒ 同样回退省码 */
+    @Test
+    void pickForOrder_ipPickYieldsShopWithoutThirdShopId_fallsBackToProvinceCode() {
+        when(ipRegionResolver.lookup("114.114.114.114")).thenReturn(Optional.of(ipGeoHunan("长沙")));
+        when(shopMapper.selectList(any())).thenReturn(List.of(
+                shop(null, "430000", "430100", HarShopCreateStatus.SUCCESS, "S-PLATFORM"),
+                ok("NJ", "320000", "320100")));
+
+        assertEquals("NJ", service.pickForOrder("M001", "320000", "114.114.114.114", "BIZ-1").getThirdShopId());
+    }
+
+    /** IP 解析不出(内网/非大陆/取数失败)⇒ 有省码就按省挑 —— 这正是「两个可以同时传」的用法 */
+    @Test
+    void pickForOrder_ipUnresolvable_fallsBackToProvinceCode() {
+        when(ipRegionResolver.lookup("1.2.3.4")).thenReturn(Optional.empty());
+        when(shopMapper.selectList(any())).thenReturn(List.of(ok("NJ", "320000", "320100")));
+
+        assertEquals("NJ", service.pickForOrder("M001", "320000", "1.2.3.4", "BIZ-1").getThirdShopId());
+    }
+
+    /** 解析出了但省名不在目录里 ⇒ 同样回退省码(港澳台已被解析器在更上游挡掉) */
+    @Test
+    void pickForOrder_ipRegionNameNotInCatalog_fallsBackToProvinceCode() {
+        when(ipRegionResolver.lookup("1.2.3.4"))
+                .thenReturn(Optional.of(new HarIpRegionResolver.IpGeo("CN", "某某省", "XX", "某市")));
+        when(shopMapper.selectList(any())).thenReturn(List.of(ok("NJ", "320000", "320100")));
+
+        assertEquals("NJ", service.pickForOrder("M001", "320000", "1.2.3.4", "BIZ-1").getThirdShopId());
+    }
+
+    /** 只传 ip 且解析不出:是调用方的输入错误,响亮拒绝 —— 静默不带门店下单 = 地区意图悄悄落空 */
+    @Test
+    void pickForOrder_ipOnly_unresolvable_400() {
+        when(ipRegionResolver.lookup("1.2.3.4")).thenReturn(Optional.empty());
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", null, "1.2.3.4", "BIZ-1"));
+
+        assertEquals(400, e.getCode());
+        assertEquals("用户 IP 无法解析出归属地: 1.2.3.4", e.getMessage());
+        verify(shopMapper, org.mockito.Mockito.never()).selectList(any());
+    }
+
+    /** 只传 ip、解析出了地区但一家店都没有:报的是**那个地区**,不是「解析不出」 */
+    @Test
+    void pickForOrder_ipOnly_regionHasNoShop_400_namesTheIpRegion() {
+        when(ipRegionResolver.lookup("114.114.114.114")).thenReturn(Optional.of(ipGeoHunan("长沙")));
+        when(shopMapper.selectList(any())).thenReturn(List.of(ok("NJ", "320000", "320100")));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", null, "114.114.114.114", "BIZ-1"));
+
+        assertEquals("该地区没有可用门店: 430100(省 430000)", e.getMessage());
+    }
+
+    /** 两个都没传(上线路径由 create 的守卫挡住,这里防将来直调时抛 NPE) */
+    @Test
+    void pickForOrder_neitherIpNorProvince_400() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", null, null, "BIZ-1"));
+
+        assertEquals(400, e.getCode());
+    }
+
+    /** 只传省份、省内多店、没传 out_biz_no ⇒ 随机种子,仍要散开(不能退化成固定第一家) */
+    @Test
+    void pickForOrder_provinceOnly_blankSeed_spreadsAcrossCalls() {
+        when(shopMapper.selectList(any())).thenReturn(List.of(
+                ok("A", "320000", "320100"),
+                ok("B", "320000", "320100"),
+                ok("C", "320000", "320500"),
+                ok("D", "320000", "320500")));
+
+        Set<String> chosen = new HashSet<>();
+        for (int i = 0; i < 60; i++) {
+            chosen.add(service.pickForOrder("M001", "320000", null, null).getThirdShopId());
+        }
+
+        assertTrue(chosen.size() > 1, "没传 out_biz_no 时也必须是随机挑而不是固定第一家,实际: " + chosen);
+        assertTrue(Set.of("A", "B", "C", "D").containsAll(chosen));
+    }
+
+    /** 稳定散列的承诺不变:给了 out_biz_no,同一单重复挑落同一家(开放文档 10.2 写死了这条) */
+    @Test
+    void pickForOrder_sameOutBizNo_isStable() {
+        when(shopMapper.selectList(any())).thenReturn(List.of(
+                ok("A", "320000", "320100"),
+                ok("B", "320000", "320100"),
+                ok("C", "320000", "320500"),
+                ok("D", "320000", "320500")));
+
+        String first = service.pickForOrder("M001", "320000", null, "BIZ-1").getThirdShopId();
+        for (int i = 0; i < 10; i++) {
+            assertEquals(first, service.pickForOrder("M001", "320000", null, "BIZ-1").getThirdShopId());
+        }
+    }
+
+    @Test
+    void pickForOrder_provinceOnly_stillPicksWithinProvince() {
+        when(shopMapper.selectList(any())).thenReturn(List.of(
+                ok("SZ", "320000", "320500"),
+                ok("HZ", "330000", "330100")));
+
+        HarShopEntity picked = service.pickForOrder("M001", "320000", null, "BIZ-1");
+
+        assertEquals("SZ", picked.getThirdShopId(), "不给 IP ⇒ 按全省挑,异省的店绝不越界");
+    }
+
+    @Test
+    void pickForOrder_unknownProvinceCode_400_sameMessageAsShopCreate() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", "999999", null, "BIZ-1"));
+
+        assertEquals(400, e.getCode());
+        assertEquals("省份编码不存在: 999999", e.getMessage(),
+                "与门店创建那条必须一字不差 —— 开放文档 10.1/10.2 引用的是同一句话");
+        verify(shopMapper, org.mockito.Mockito.never()).selectList(any());
+    }
+
+    @Test
+    void pickForOrder_noUsableShopInProvince_400_namesTheProvince() {
+        when(shopMapper.selectList(any())).thenReturn(List.of(ok("HZ", "330000", "330100")));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", "320000", null, "BIZ-1"));
+
+        assertEquals("该地区没有可用门店: 省 320000", e.getMessage());
+    }
+
+    /**
+     * 挑中了但 third_shop_id 为空 ⇒ 与「没挑到」同一条 400。结账腿有同样的守卫
+     * ({@code pickShopForCustomer} 那两行);不守的话会拿一个空编码去下单,
+     * 调用方以为挑店生效了,实际风控归属是空的。
+     */
+    @Test
+    void pickForOrder_pickedShopWithoutThirdShopId_400() {
+        when(shopMapper.selectList(any())).thenReturn(List.of(
+                shop(null, "320000", "320100", HarShopCreateStatus.SUCCESS, "S-PLATFORM")));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.pickForOrder("M001", "320000", null, "BIZ-1"));
+
+        assertEquals("该地区没有可用门店: 省 320000", e.getMessage());
     }
 }

+ 14 - 11
java/src/test/java/com/payment/platform/module/payment/har/service/HarShopServiceTest.java

@@ -39,6 +39,8 @@ class HarShopServiceTest {
     @Mock private HarShopMapper shopMapper;
     @Mock private HarMerchantService merchantService;
     @Mock private HarClient harClient;
+    /** 本类只测门店创建;挑店那套在 HarShopRegionPickTest,这里只需要一个不炸的替身 */
+    @Mock private HarIpRegionResolver ipRegionResolver;
 
     private HarShopService service;
 
@@ -46,7 +48,7 @@ class HarShopServiceTest {
     void setUp() {
         // 地区目录用**真家伙**(纯内存、无依赖,读的是打进包里的快照):
         // 门店创建时它要校验省市编码,mock 掉就等于把这道校验从每条 create 用例里摘掉了。
-        service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog());
+        service = new HarShopService(shopMapper, merchantService, harClient, new HarRegionCatalog(), ipRegionResolver);
         // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
         // (否则 getSqlSegment() 抛 MybatisPlusException: can not find lambda cache for this entity。
         //   同款引导见 LsyCallbackServiceTest / AlipayBatchPayServiceTest。)
@@ -60,8 +62,9 @@ class HarShopServiceTest {
         dto.setThirdShopId("SHOP001");
         dto.setShopName("测试门店");
         // 省市编码自 2026-10-08 起上游必填,本地也会对目录校验 —— 缺了走不到调上游那一步
-        dto.setProvinceCode("32");
-        dto.setCityCode("3201");
+        // (2026-10-09 起是 GB/T 2260 六位码,形状由 HarRegionCatalogTest 守卫)
+        dto.setProvinceCode("320000");
+        dto.setCityCode("320100");
         return dto;
     }
 
@@ -241,17 +244,17 @@ class HarShopServiceTest {
 
         ArgumentCaptor<HarShopCreateRequest> captor = ArgumentCaptor.forClass(HarShopCreateRequest.class);
         verify(harClient).createShop(captor.capture());
-        assertEquals("32", captor.getValue().getProvinceCode());
-        assertEquals("3201", captor.getValue().getCityCode());
+        assertEquals("320000", captor.getValue().getProvinceCode());
+        assertEquals("320100", captor.getValue().getCityCode());
 
         String biz = HarClient.toBizContent(captor.getValue());
-        assertTrue(biz.contains("\"province_code\":\"32\""), "实际报文: " + biz);
-        assertTrue(biz.contains("\"city_code\":\"3201\""), "实际报文: " + biz);
+        assertTrue(biz.contains("\"province_code\":\"320000\""), "实际报文: " + biz);
+        assertTrue(biz.contains("\"city_code\":\"320100\""), "实际报文: " + biz);
 
         ArgumentCaptor<HarShopEntity> row = ArgumentCaptor.forClass(HarShopEntity.class);
         verify(shopMapper).insert(row.capture());
-        assertEquals("32", row.getValue().getProvinceCode());
-        assertEquals("3201", row.getValue().getCityCode());
+        assertEquals("320000", row.getValue().getProvinceCode());
+        assertEquals("320100", row.getValue().getCityCode());
     }
 
     /**
@@ -264,7 +267,7 @@ class HarShopServiceTest {
     @Test
     void create_rejectsCityCodeThatIsNotInTheGivenProvince() {
         HarShopCreateDTO dto = dto();
-        dto.setCityCode("3301");   // 杭州,挂在江苏名下
+        dto.setCityCode("330100");   // 杭州,挂在江苏名下
 
         BusinessException e = assertThrows(BusinessException.class, () -> service.create(dto, "E1"));
 
@@ -281,7 +284,7 @@ class HarShopServiceTest {
     @Test
     void create_rejectsUnknownProvinceCode() {
         HarShopCreateDTO dto = dto();
-        dto.setProvinceCode("99");
+        dto.setProvinceCode("999999");
 
         BusinessException e = assertThrows(BusinessException.class, () -> service.create(dto, "E1"));
 

+ 50 - 42
java/src/test/java/com/payment/platform/module/payment/har/util/HarRegionCatalogTest.java

@@ -36,13 +36,14 @@ class HarRegionCatalogTest {
     // ──────────────────────────── 快照完整性 ────────────────────────────
 
     @Test
-    void provinces_snapshotIs31_uniqueTwoDigitCodes() {
+    void provinces_snapshotIs31_uniqueSixDigitCodes() {
         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.code().matches("\\d{2}0000")),
+                "省级编码必须是 GB/T 2260 六位(2 位省码 + 0000)—— 上游对照表(2026-10-09 截图)就是六位");
         assertTrue(ps.stream().allMatch(p -> !p.name().isBlank()), "省级名称不能为空");
     }
 
@@ -54,9 +55,10 @@ class HarRegionCatalogTest {
 
         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 -> c.code().matches("\\d{4}00")),
+                "地级编码必须是 GB/T 2260 六位(4 位市码 + 00)");
+        assertTrue(all.stream().allMatch(c -> c.code().startsWith(c.provinceCode().substring(0, 2))),
+                "地级编码前两位必须是省码的前两位 —— 前缀断裂会让「按省回退」捞到别的省的门店");
         assertTrue(all.stream().allMatch(c -> catalog.provinces().stream()
                         .anyMatch(p -> p.code().equals(c.provinceCode()))),
                 "地级指向了不存在的省");
@@ -78,7 +80,7 @@ class HarRegionCatalogTest {
     }
 
     /**
-     * 直辖市的城市下拉不能出现「市辖区」(GB/T 2260 里 1101/1201/3101/5001 的名字),
+     * 直辖市的城市下拉不能出现「市辖区」(GB/T 2260 里 110100/120100/310100/500100 的名字),
      * 顾客与商户读到的应该是省名。
      */
     @Test
@@ -148,16 +150,17 @@ class HarRegionCatalogTest {
     /** 输入全部抄自 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("江苏省"));
+        assertEquals("540000", provinceCode("西藏自治區"), "实测回包里的繁体「區」");
+        assertEquals("540000", provinceCode("西藏自治区"));
+        assertEquals("650000", provinceCode("新疆维吾尔自治区"));
+        assertEquals("450000", provinceCode("广西壮族自治区"));
+        assertEquals("640000", provinceCode("宁夏回族自治区"));
+        assertEquals("150000", provinceCode("内蒙古自治区"));
+        assertEquals("150000", provinceCode("内蒙古"));
+        assertEquals("110000", provinceCode("北京市"));
+        assertEquals("110000", provinceCode("北京"));
+        assertEquals("320000", provinceCode("江苏省"));
+        assertEquals("430000", provinceCode("湖南省"), "实测回包(118.248.202.107)");
     }
 
     @Test
@@ -172,34 +175,37 @@ class HarRegionCatalogTest {
 
     @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", "無錫市"), "繁体整串也要能命中");
+        assertEquals("320100", cityCode("320000", "南京"));
+        assertEquals("320100", cityCode("320000", "南京市"));
+        assertEquals("652300", cityCode("650000", "昌吉回族自治州"));
+        assertEquals("540100", cityCode("540000", "拉萨"));
+        assertEquals("540100", cityCode("540000", "拉薩"), "同一条实测回包里 city 是简体,但别的字段出现过繁体");
+        assertEquals("320200", cityCode("320000", "無錫市"), "繁体整串也要能命中");
+        // 实测回包(118.248.202.107):省名带「省」、市名不带「市」——两侧都要能命中同一个地级
+        assertEquals("430100", cityCode("430000", "长沙"), "实测回包 city 不带后缀");
+        assertEquals("430100", cityCode("430000", "长沙市"));
     }
 
     @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(), "省码不存在");
+        assertTrue(catalog.matchCity("500000", "渝中區").isEmpty(), "实测回包里重庆市回的是区级名");
+        assertTrue(catalog.matchCity("110000", "").isEmpty());
+        assertTrue(catalog.matchCity("110000", null).isEmpty());
+        assertTrue(catalog.matchCity("320000", "Tokyo").isEmpty());
+        assertTrue(catalog.matchCity("999999", "南京").isEmpty(), "省码不存在");
     }
 
     /**
-     * 直辖市的 1101/1201/3101 展示成省名,于是「北京市 + 北京」能命中市一级。
+     * 直辖市的 110100/120100/310100 展示成省名,于是「北京市 + 北京」能命中市一级。
      *
      * <p>★ 重庆(50)**有意不在**这条用例里:实测它的回包给的是区级名(渝中區),
      * 走的是「市级未命中 ⇒ 回退全省」那一步;这里不写我没量到过的行为。
      */
     @Test
     void municipalities_matchAtCityLevelViaTheProvinceName() {
-        assertEquals("1101", cityCode("11", "北京"));
-        assertEquals("3101", cityCode("31", "上海"));
-        assertEquals("1201", cityCode("12", "天津"));
+        assertEquals("110100", cityCode("110000", "北京"));
+        assertEquals("310100", cityCode("310000", "上海"));
+        assertEquals("120100", cityCode("120000", "天津"));
     }
 
     // ──────────────────────────── 按编码查(门店创建校验用) ────────────────────────────
@@ -214,18 +220,20 @@ class HarRegionCatalogTest {
     @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());
+        assertEquals(catalog.matchCity("320000", "南京").orElseThrow(),
+                catalog.cityByCode("320000", "320100").orElseThrow());
+
+        assertTrue(catalog.cityByCode("320000", "330100").isEmpty(), "330100 是浙江的市码,不该挂在江苏名下");
+        assertTrue(catalog.cityByCode("999999", "320100").isEmpty(), "省码不存在");
+        assertTrue(catalog.cityByCode("320000", "3201").isEmpty(), "旧四位短码已作废(2026-10-09 起六位)");
+        assertTrue(catalog.cityByCode("32", "320100").isEmpty(), "旧两位省码已作废");
+        assertTrue(catalog.cityByCode("320000", "32010").isEmpty(), "位数不对");
+        assertTrue(catalog.cityByCode("320000", "320000").isEmpty());
+        assertTrue(catalog.cityByCode("320000", null).isEmpty());
+        assertTrue(catalog.cityByCode(null, "320100").isEmpty());
+        assertTrue(catalog.cityByCode("320000", "  ").isEmpty());
         // 正控:同一个量具对**该在**的码必须命中,否则上面那串空可能只是它永远查不到东西
-        assertTrue(catalog.cityByCode("32", "3201").isPresent());
+        assertTrue(catalog.cityByCode("320000", "320100").isPresent());
     }
 
     /** 完备性:数据集里每一个市码都要按码查得到,且查回来还是它自己 */