1
0

22 Коммитууд 06c70a78b1 ... 7a2ec244fd

Эзэн SHA1 Мессеж Огноо
  alphaH 7a2ec244fd fix(转账): 失败通知把 error_code / fail_reason 丢掉了 —— 两列一直空着 2 долоо хоног өмнө
  alphaH c1afbd1595 chore(乐收赢): 补交租户角色页面授权 SQL(此前一直只在工作区) 2 долоо хоног өмнө
  alphaH f2002b6a46 test(乐收赢): 退款回调修复的两支探针(直连库读数 + HTTP 端到端) 2 долоо хоног өмнө
  alphaH 436df3b52d feat(乐收赢): 订单抽屉显示已退金额与退款单 2 долоо хоног өмнө
  alphaH 851d622ada feat(乐收赢): 订单详情补退款单端点,回调轨迹纳入退款行的 trace 2 долоо хоног өмнө
  alphaH 92555d56ea fix(乐收赢): 退款回调此前落在支付腿上 —— 按订单 trace 查必然查不到 2 долоо хоног өмнө
  alphaH e7993eeb34 fix(乐收赢): 订单页「回调轨迹」两列补中文映射 —— 处理后/验签之前裸显英文枚举 2 долоо хоног өмнө
  alphaH 9a32cecca0 fix(乐收赢): 上送上游的 notify_url 改用平台配置,不再转发调用方地址 2 долоо хоног өмнө
  alphaH 220f4ebc34 test(乐收赢): 密钥体检补一把宽量具 —— 扫全部 git 跟踪文件,不只 logs/ 2 долоо хоног өмнө
  alphaH db039aebe3 test(乐收赢): 补上下单腿的两发原始应答与探针 —— 它们是已入库那几个探针的输入 2 долоо хоног өмнө
  alphaH 7d4d3511a3 test(乐收赢): 补上 674b814 引用的证据文件,并把密钥体检的覆盖面改成递归 2 долоо хоног өмнө
  alphaH 282b1201e3 docs(乐收赢): 订正 LsyOrderService 里那句被证伪的 H5 关单注释 2 долоо хоног өмнө
  alphaH 674b8143c1 fix(乐收赢): 对账腿把上游「查无此单」从带栈 ERROR 降成不带栈 WARN 2 долоо хоног өмнө
  alphaH ed310fcb1d test(乐收赢): 全生命周期联调实测台与四轮读数(收单/查单/退款/关单/撤销/补录) 2 долоо хоног өмнө
  alphaH 91280dd359 fix(乐收赢): H5 关单改打上游 /v3/closeorder(原为「不动上游、本地收敛」) 2 долоо хоног өмнө
  alphaH 380630f397 test(乐收赢): 补一条**真实** PAY_IN_PROCESS 应答的向量,并用反向守卫钉住「以 result_code 为准」 2 долоо хоног өмнө
  alphaH b64bc299b3 docs(乐收赢): 订正 applyPayResult 里那段「为什么不放 payPatch」的注释 2 долоо хоног өмнө
  alphaH e753839faf test(乐收赢): 入站验签补第四条向量 —— 上游对**空字符串**签名的真实应答 2 долоо хоног өмнө
  alphaH c58800eb67 test(乐收赢): 用测试环境的**真实应答**做入站验签向量(上游本人给的,非文档、非自算) 2 долоо хоног өмнө
  alphaH bf0901673d fix(乐收赢): 三个收款页的 keep-alive 组件名与菜单 route_name 对齐(此前静默失效) 2 долоо хоног өмнө
  alphaH 418487d941 test(乐收赢): 补环境变量形态实测;顺带实测出 JAVA_OPTS 关开关是空操作 2 долоо хоног өмнө
  alphaH b6da54ad5a feat(乐收赢): 通道加总开关 leshouying.enabled,默认 true 2 долоо хоног өмнө
100 өөрчлөгдсөн 2975 нэмэгдсэн , 131 устгасан
  1. 68 0
      .claude/plan/2026-09-23-lsy-onboarding-B-harness/README.md
  2. 170 0
      .claude/plan/2026-09-23-lsy-onboarding-B-harness/lsy-keepalive-harness.mjs
  3. 9 0
      docker-compose.yml
  4. 14 0
      frontend/src/api/module_payment/collection.ts
  5. 6 1
      frontend/src/views/module_payment/collection/merchant/index.vue
  6. 145 3
      frontend/src/views/module_payment/collection/order/index.vue
  7. 3 1
      frontend/src/views/module_payment/collection/refund/index.vue
  8. 33 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java
  9. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java
  10. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantController.java
  11. 5 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingNotifyController.java
  12. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java
  13. 62 1
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.java
  14. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingRefundController.java
  15. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java
  16. 3 0
      java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyOrderPollScheduler.java
  17. 74 2
      java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java
  18. 91 15
      java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCancelService.java
  19. 113 27
      java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyService.java
  20. 77 16
      java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java
  21. 11 1
      java/src/main/java/com/payment/platform/module/payment/notification/handler/AccountHandler.java
  22. 103 0
      java/src/main/resources/db/lsy-tenant-page-grants.sql
  23. 58 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingResponseParserTest.java
  24. 113 2
      java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSignerTest.java
  25. 308 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingChannelSwitchWiringTest.java
  26. 56 1
      java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingPropertiesStartupTest.java
  27. 211 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderControllerTest.java
  28. 155 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.java
  29. 60 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyBarcodePayTest.java
  30. 85 8
      java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCancelServiceTest.java
  31. 21 1
      java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyH5PayTest.java
  32. 142 52
      java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyServiceTest.java
  33. 23 0
      java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyQrPayTest.java
  34. 98 0
      java/src/test/java/com/payment/platform/module/payment/notification/handler/AccountHandlerTest.java
  35. 204 0
      logs/_lsy-close-control.py
  36. 50 0
      logs/_lsy-close-control.txt
  37. 1 0
      logs/_lsy-ctl/A-after.bin
  38. 1 0
      logs/_lsy-ctl/A-baseline.bin
  39. 1 0
      logs/_lsy-ctl/A-qr-after.bin
  40. 1 0
      logs/_lsy-ctl/A-qr-baseline.bin
  41. 1 0
      logs/_lsy-ctl/B-after.bin
  42. 1 0
      logs/_lsy-ctl/B-baseline.bin
  43. 1 0
      logs/_lsy-ctl/B-qr-after.bin
  44. 1 0
      logs/_lsy-ctl/B-qr-baseline.bin
  45. 1 0
      logs/_lsy-ctl/close-A.bin
  46. 1 0
      logs/_lsy-ctl/h5-A-create.bin
  47. 1 0
      logs/_lsy-ctl/h5-B-create.bin
  48. 1 0
      logs/_lsy-ctl/qr-A-create.bin
  49. 1 0
      logs/_lsy-ctl/qr-B-create.bin
  50. 1 0
      logs/_lsy-ctl/qrclose-A.bin
  51. 176 0
      logs/_lsy-h5pay-probe.py
  52. 71 0
      logs/_lsy-h5pay-probe.txt
  53. 1 0
      logs/_lsy-h5pay-resp.bin
  54. 74 0
      logs/_lsy-key-scan.py
  55. 8 0
      logs/_lsy-key-scan.txt
  56. 1 0
      logs/_lsy-lc/cancel-nonexistent.bin
  57. 1 0
      logs/_lsy-lc/close-qr-1st.bin
  58. 1 0
      logs/_lsy-lc/close-qr-2nd.bin
  59. 1 0
      logs/_lsy-lc/close-qr-nocodeurl.bin
  60. 1 0
      logs/_lsy-lc/close-qr-nonexistent.bin
  61. 1 0
      logs/_lsy-lc/closeorder-h5-bytradeno.bin
  62. 1 0
      logs/_lsy-lc/closeorder-qr-bytrace.bin
  63. 1 0
      logs/_lsy-lc/create-h5pay.bin
  64. 1 0
      logs/_lsy-lc/create-pay-badcode.bin
  65. 1 0
      logs/_lsy-lc/create-pay-barcode.bin
  66. 1 0
      logs/_lsy-lc/create-qrpay-dup.bin
  67. 1 0
      logs/_lsy-lc/create-qrpay-full.bin
  68. 1 0
      logs/_lsy-lc/create-qrpay-min.bin
  69. 1 0
      logs/_lsy-lc/query-h5-both-keys.bin
  70. 1 0
      logs/_lsy-lc/query-h5-bytrace.bin
  71. 1 0
      logs/_lsy-lc/query-h5-bytradeno.bin
  72. 1 0
      logs/_lsy-lc/query-h5-traceONLY.bin
  73. 1 0
      logs/_lsy-lc/query-nokey.bin
  74. 1 0
      logs/_lsy-lc/query-nonexistent.bin
  75. 1 0
      logs/_lsy-lc/query-qrpay-afterclose.bin
  76. 1 0
      logs/_lsy-lc/query-qrpay-unscanned.bin
  77. 1 0
      logs/_lsy-lc/query-supply-bytrace.bin
  78. 1 0
      logs/_lsy-lc/query-supply-bytradeno.bin
  79. 1 0
      logs/_lsy-lc/queryrefund-byno.bin
  80. 1 0
      logs/_lsy-lc/queryrefund-bytrace.bin
  81. 1 0
      logs/_lsy-lc/refund-nonexistent.bin
  82. 1 0
      logs/_lsy-lc/refund-overamount.bin
  83. 1 0
      logs/_lsy-lc/refund-supply-bytrace.bin
  84. 1 0
      logs/_lsy-lc/refund-unpaid-qrpay.bin
  85. 1 0
      logs/_lsy-lc/supply-1.bin
  86. 1 0
      logs/_lsy-lc2/cancel-barcode-bytradeno.bin
  87. 1 0
      logs/_lsy-lc2/q-barcode-aftercancel.bin
  88. 1 0
      logs/_lsy-lc2/qrefund-byno-again.bin
  89. 1 0
      logs/_lsy-lc2/qrefund-bytrace-fixed.bin
  90. 1 0
      logs/_lsy-lc2/refund-closed-h5.bin
  91. 1 0
      logs/_lsy-lc2/refund-tradeno-610001.bin
  92. 1 0
      logs/_lsy-lc2/supply-ch1.bin
  93. 1 0
      logs/_lsy-lc2/supply-ch2.bin
  94. 1 0
      logs/_lsy-lc2/supply-ch4.bin
  95. 1 0
      logs/_lsy-lc2/supply-ch50.bin
  96. 1 0
      logs/_lsy-lc2/supply-ch6.bin
  97. 1 0
      logs/_lsy-lc2/supply-flag2.bin
  98. 1 0
      logs/_lsy-lc3/barcode-for-cancel.bin
  99. 1 0
      logs/_lsy-lc3/cancel-barcode-2nd.bin
  100. 1 0
      logs/_lsy-lc3/cancel-barcode-bytrace.bin

+ 68 - 0
.claude/plan/2026-09-23-lsy-onboarding-B-harness/README.md

@@ -180,3 +180,71 @@ node .claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-update-mutate.mjs resto
 **核出 CRLF 那组数不是文件被改过,是行尾。**
 (上面两张表共 12 个读数,是用同一个换算脚本一次跑出来的:把读入的字节按 `\r\n` ↔ `\n` 互转后各自 sha256。
 该脚本对 `task-9-fix1-harness.mjs` 复现出的 CRLF 值 `81271e98…` 与早先独立量到的一致 ⇒ 换算方式没跑偏。)
+
+---
+
+## 5. `lsy-keepalive-harness.mjs` —— keep-alive 组件名匹配(2026-09-28 新增)
+
+**要证的一句话**:`<keep-alive :include>` 匹配的是**组件名**,而 `cachedViews` 里放的是
+**路由名**(= `sys_menu.route_name`)⇒ 两者不逐字相同时,那页的 `keep_alive=true`
+**静默失效**(vue 不报错,只是不缓存;症状是「切走再切回来,筛选条件/分页/正在等的二维码全没了」)。
+
+链路三跳(全部从源码量过,不是推的):
+`sys_menu.route_name` → `permission.store.ts` 造路由时 `name: item.route_name`
+→ `tags-view.store.ts` 的 `addCachedView` 把**路由名**推进 `cachedViews`
+→ `layouts/components/AppMain/index.vue:6` 的 `<keep-alive :include="cachedViews">` 拿它去比**组件名**。
+
+```bash
+node .claude/plan/2026-09-23-lsy-onboarding-B-harness/lsy-keepalive-harness.mjs \
+  "D:/project2/payment-platform/frontend/src/views/module_payment/collection/order/index.vue" order \
+  [可选的 include 覆盖值]
+```
+
+编**真** SFC(`@vue/compiler-sfc`)、用 `frontend/node_modules` 里那份**真** `vue` 挂进**真**
+`<keep-alive>`,数 `setup()` 跑了几次 —— 缓存命中后再激活**不会**重跑 `setup`。
+一次跑给**两向**读数:`include=[本页真名]` 期望 1;`include=[必然不匹配的串]` 期望 2(正对照,
+没有它,`setups=1` 也可能只是计数器没在工作)。
+
+### 读数(2026-09-28 实跑)
+
+五个页面各自自比,**5/5 PASS**(`hit=1 miss=2`):
+
+| 页面 | 组件名(修后) | 活库 `route_name` |
+|---|---|---|
+| `collection/order` | `CollectionOrder` | `CollectionOrder`(id=269) |
+| `collection/refund` | `CollectionRefund` | `CollectionRefund`(id=270) |
+| `collection/merchant` | `CollectionMerchant` | `CollectionMerchant`(id=271) |
+| `collection/cashier` | `CollectionCashier` | `CollectionCashier` |
+| `collection/onboarding` | `CollectionOnboarding` | `CollectionOnboarding`(id=272) |
+
+活库那一列取自 `lsy-cashier-menu.sql` 头部记的 **2026-09-23 只读实测**(id 269–272)。
+★ 前三个页面**在 2026-09-28 之前**写的是 `Leshouying*`(`lsy-menu-rename.sql` 把库从
+`Leshouying*` 改成 `Collection*` 时,SFC 的 `name` 没跟着改)—— 即**三个页面的 `keep_alive` 一直是失效的**。
+`lsy-cashier-menu.sql:32-40` 早记过这条,当时按「不相关的不动」留下未修。
+
+**变异(两向的另一半)**:把 order 页的 `name` 改回 `LeshouyingOrder`,`include` 仍喂
+`CollectionOrder` ⇒ **`SUMMARY FAIL hit=2 miss=2`**(不缓存)。这证明上面那些 `PASS` 是
+**行为差别**,不是文字差别。还原后 `git status -- frontend/` 只剩那三个待提交文件。
+
+### 已知局限(别把它的绿读大)
+
+- 模板**不渲染**(`inlineTemplate: false`)⇒ 它证明的是**缓存语义**,**不是**浏览器里长什么样。
+- 用 `createRenderer` 接一套哑节点操作,**不引 jsdom**。有的页面 `onMounted` 会碰 `window`
+  (如 cashier 页),台子里有一行 `globalThis.window = globalThis` 的垫片 ——
+  ★ 那是**台子**的垫片,不是页面的缺陷,别去改页面。
+- 桩是通用的(`@/api/*` 与 `element-plus` 都换成「取什么都是可调用、返回 Promise」的代理)
+  ⇒ 它验不到任何接口契约。**残留 import 会被一句兜底断言当场炸掉**,不会悄悄跑出假绿
+  (实测:`qrcode` 与 `SingleImageUpload` 两条就是这么被发现并补上的)。
+- 不设 `process.exitCode`(与同目录各台一致)⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。
+
+### 指纹
+
+| 文件 | LF 字节 | LF sha256 | CRLF 字节 | CRLF sha256 |
+|---|---|---|---|---|
+| `lsy-keepalive-harness.mjs` | 7718 | `2d5a9c9a677dafd1d57d84eae6e0afc8d11ce7526d0aae2e66dffa15327c7571` | 7888 | `cd97a29a36877aeb88656b2271d232e960850f7cc3c94329b256ed009a04d1fe` |
+| `collection/order/index.vue` | 10531 | `66b144354bb612081aeca65ef54b002c194f29ec2eab5d840fea7fa6ea7b9700` | 10789 | `1559fbb99ad173c7995f5491dfdd96def53b8b894dfc38fcb41ea30e73ef315a` |
+| `collection/refund/index.vue` | 4720 | `0a6c782061522baad7a97a794f51f78b214bcf960141aed6546e8a8291322b2b` | 4860 | `1c00b8b0f89f46adaf8d0a64999817415c16194bf2e5fe8218d3a72493df5ea7` |
+| `collection/merchant/index.vue` | 11178 | `2df3a00d5440b08487cd409d9923b72dd76fa5659fc75a436e4e900ebcb449b6` | 11431 | `bebaac71b14e2c331d32c96d7d55d83fbc255b8a9b4233307891bf1a3ab60942` |
+
+三个 `.vue` 的 `git ls-files --eol` 实测 `i/lf w/crlf`(工作区即 CRLF 形态);
+`.mjs` 是 2026-09-28 新写的,工作区当前是 LF(CR 数 0)—— 提交后随 `core.autocrlf=true` 会变 CRLF。

+ 170 - 0
.claude/plan/2026-09-23-lsy-onboarding-B-harness/lsy-keepalive-harness.mjs

@@ -0,0 +1,170 @@
+/**
+ * keep-alive 组件名匹配的执行级验证台。
+ *
+ * 【要证的一句话】`<keep-alive :include>` 匹配的是**组件名**,而 `cachedViews` 里放的是
+ * **路由名**(=`sys_menu.route_name`)⇒ 两者不逐字相同时,那页的 `keep_alive=true`
+ * **静默失效**(vue 不报错,只是不缓存)。乐收赢 `collection/` 下三个页面正是这个状态。
+ *
+ * 【为什么不能只看源码】「名字现在对上了」与「现在真的会缓存」是两句不同的话。
+ * 前者是文本读数,后者是运行时行为。本台测**后者**:编译**真**的 SFC(`@vue/compiler-sfc`)、
+ * 用 `frontend/node_modules` 里那份**真** `vue` 挂进**真** `<keep-alive>`,
+ * 数 `setup()` 被调用了几次 —— 缓存命中时再激活**不会**重跑 `setup`。
+ *
+ * 【两向读数一次跑完】`include` 给两个值:
+ *   ① 该页面真实的组件名      ⇒ 期望 setups=1(命中缓存)
+ *   ② 一个必然不匹配的串      ⇒ 期望 setups=2(未缓存)
+ * ② 是 ① 的正对照:没有它,setups=1 也可能只是「计数器根本没在工作」。
+ *
+ * 【不做 DOM】用 `createRenderer` 接一套哑节点操作,不引 jsdom。
+ * 【模板不渲染】(`inlineTemplate: false`)⇒ 本台证明的是**缓存语义**,不是页面长什么样。
+ * 【不设 process.exitCode】与同目录各台一致 ⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。
+ *
+ * 用法:node lsy-keepalive-harness.mjs <index.vue 路径> [标签]
+ */
+import fs from "node:fs";
+import { createRequire } from "node:module";
+
+const FE = "D:/project2/payment-platform/frontend/";
+const require = createRequire(FE + "package.json");
+const vue = require("vue");
+const { parse, compileScript } = require("@vue/compiler-sfc");
+
+const SRC = process.argv[2];
+const LABEL = process.argv[3] || SRC;
+
+/** 把 SFC 编译成可 `new Function` 的普通脚本(配方抄自同目录 fe-update-harness.mjs)。 */
+function compileSfc(file) {
+  const src = fs.readFileSync(file, "utf8");
+  const { descriptor, errors } = parse(src, { filename: file });
+  if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
+  let code = compileScript(descriptor, { id: "kaprobe", inlineTemplate: false }).content;
+
+  const braceImport = (spec) =>
+    new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
+  const asStub = (slot) => (_m, n) =>
+    `const {${n.replace(/\btype\s+[\w$.]+\s*,?/g, "").replace(/\bas\b/g, ":")}} = globalThis.__U.${slot};`;
+  code = code
+    // ★ 会有**两条** vue import(`defineOptions` 带出的 `defineComponent` 一条、文件自己一条)
+    //   ⇒ 正则必须带 `g`。
+    .replace(braceImport("vue"), asStub("vue"))
+    // 业务接口与 UI 库都只打桩(模板不渲染,它们不会有真行为)。
+    // ★ 下面那句兜底断言是**故意**留的:漏了哪个 import 会当场炸,而不是悄悄跑出假绿。
+    .replace(braceImport("@/api/module_payment/collection"), asStub("api"))
+    .replace(braceImport("@/api/module_payment/onboarding"), asStub("api"))
+    .replace(braceImport("element-plus"), asStub("ep"))
+    // 默认导入(`import QRCode from "qrcode"` / 子组件)—— 正则与上面那族**不同形**,
+    // ★ 漏掉它时上面那句兜底断言会当场炸(实测:cashier 页就是靠它发现的)。
+    .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, (_m, n) => `const ${n} = globalThis.__U.ep;`)
+    .replace(
+      /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
+      (_m, n) => `const ${n} = globalThis.__U.ep;`
+    );
+
+  if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
+
+  const ts = require("typescript");
+  code = ts.transpileModule(code, {
+    compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
+    fileName: "index.ts",
+  }).outputText;
+  code = code.replace(/export\s+default/, "const __sfc =");
+  if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export");
+  code += "\nglobalThis.__U.sfc = __sfc;";
+  return code;
+}
+
+// ---------- 桩 ----------
+// 页面 `onMounted` 会打这几个接口;模板不渲染,所以只要它别抛就行。
+const okPage = () => ({ data: { code: 0, data: { list: [], total: 0 } } });
+const apiStub = new Proxy(
+  {},
+  { get: (_t, name) => (name === "then" ? undefined : async () => okPage()) }
+);
+
+// UI 库:任何名字拿到都是「可调用、可继续取属性、调用返回 Promise」的东西
+// (`ElMessageBox.confirm(...)` 要能 await)。
+const callable = new Proxy(async () => undefined, {
+  get: (_t, k) => (k === "then" ? undefined : callable),
+  apply: () => Promise.resolve(),
+});
+const epStub = new Proxy({}, { get: () => callable });
+
+// 无 DOM 台子的垫片:有的页面 `onMounted` 里会碰 `window`(如 `window.setInterval`)。
+// ★ 这是**台子**的垫片,不是页面的问题 —— 指出来免得后人把它当成缺陷去改页面。
+if (typeof globalThis.window === "undefined") {
+  globalThis.window = globalThis;
+}
+
+globalThis.__U = { vue, api: apiStub, ep: epStub, sfc: null };
+new Function(compileSfc(SRC))();
+
+const Real = globalThis.__U.sfc;
+const REAL_NAME = Real && Real.name;
+if (!REAL_NAME) throw new Error("编译出来的组件没有 name —— defineOptions 没被保留?");
+
+// ---------- 无 DOM 渲染器 ----------
+const node = () => ({ children: [] });
+const noop = () => {};
+const { createRenderer, h, KeepAlive, ref, nextTick } = vue;
+const renderer = createRenderer({
+  createElement: node,
+  createText: node,
+  createComment: node,
+  setText: noop,
+  setElementText: noop,
+  insert: noop,
+  remove: noop,
+  parentNode: () => null,
+  nextSibling: () => null,
+  patchProp: noop,
+});
+
+/** 数 setup 跑了几次 —— 缓存命中后再激活**不会**重跑 setup。 */
+async function countSetups(includeName) {
+  let setups = 0;
+  const Wrapped = {
+    ...Real,
+    render: () => null,
+    setup(props, ctx) {
+      setups++;
+      return Real.setup ? Real.setup(props, ctx) : undefined;
+    },
+  };
+  const Other = { name: "KaprobeOther", render: () => null };
+
+  // ★ 必须是 ref:普通变量不会被追踪,`__switch` 改完不会触发重渲染,
+  //   于是「没缓存」与「根本没切页」在读数上分不开。
+  const cur = ref("target");
+  const Host = {
+    setup() {
+      globalThis.__switch = (v) => {
+        cur.value = v;
+      };
+      return () => h(KeepAlive, { include: [includeName] }, () =>
+        cur.value === "target" ? h(Wrapped) : h(Other)
+      );
+    },
+  };
+
+  renderer.createApp(Host).mount(node());
+  await nextTick();
+  globalThis.__switch("other");
+  await nextTick();
+  globalThis.__switch("target");
+  await nextTick();
+  return setups;
+}
+
+// ★ 第 4 个参数可把「命中」那一轮喂成**别的**名字 —— 用来跑变异:
+//   把页面里的 name 改成旧值,而 include 仍喂活库那个名字,此时**必须**读出不缓存。
+//   不给就是自比(include 取本页真名)。
+const INCLUDE = process.argv[4] || REAL_NAME;
+
+const hit = await countSetups(INCLUDE);
+const miss = await countSetups("__kaprobe_never_matches__");
+const verdict = hit === 1 && miss === 2 ? "PASS" : "FAIL";
+
+console.log(`组件名=${REAL_NAME}  文件=${LABEL}  include=${INCLUDE}`);
+console.log(`  include=[${INCLUDE}]  setups=${hit}   期望 1(命中缓存)`);
+console.log(`  include=[必然不匹配的串]  setups=${miss}   期望 2(未缓存,正对照)`);
+console.log(`SUMMARY ${verdict} hit=${hit} miss=${miss} name=${REAL_NAME}`);

+ 9 - 0
docker-compose.yml

@@ -8,6 +8,15 @@ services:
     environment:
       TZ: "Asia/Shanghai"
       SPRING_PROFILES_ACTIVE: prod
+      # 乐收赢通道总开关,默认 true(不写就是开,与代码里的默认值一致)。
+      # 本文件没有 env_file ⇒ ★ 宿主机上 export 了也进不了容器,必须靠这一行透传。
+      # 通道还没有真实凭据时置 false:该模块的 7 个控制器与 2 个定时任务都不装配,
+      # 应用照常启动(否则会被 LeshouyingProperties 的文档测试配置闸挡住,起不来)。
+      # 用法:LESHOUYING_ENABLED=false docker compose up -d
+      LESHOUYING_ENABLED: "${LESHOUYING_ENABLED:-true}"
+      # 关通道只走上面这一行。JAVA_OPTS 里写 -DLESHOUYING_ENABLED=false 是空操作,
+      # 实测 2026-09-28:全大写加下划线那条宽松形态是环境变量专有的,系统属性只认
+      # leshouying.enabled。症状与「压根没写这一行」一模一样,别踩。
       JAVA_OPTS: "-Xms256m -Xmx512m"
       SPRING_DATASOURCE_DRUID_URL: jdbc:postgresql://postgres:5432/payment_platform_java?currentSchema=public
       SPRING_DATASOURCE_DRUID_USERNAME: admin

+ 14 - 0
frontend/src/api/module_payment/collection.ts

@@ -43,6 +43,20 @@ export function getLsyNotifyLogs(outTradeNo: string) {
   });
 }
 
+/**
+ * 本单的退款单 —— 订单抽屉的「退款」一节用它。
+ *
+ * ★ 不要拿 `getLsyRefundPage({ keyword })` 代替:那条腿是 `LIKE out_trade_no`,
+ *   给的是「订单号**包含**本单号」的退款单,而抽屉要的是「**属于**本单的」。
+ *   两者在订单号互为前缀时会给出不同的行,且客户端无法把它收敛回去。
+ */
+export function getLsyOrderRefunds(outTradeNo: string) {
+  return request({
+    url: `/payment/collection/order/${outTradeNo}/refunds`,
+    method: "get",
+  });
+}
+
 // ==================== 退款 ====================
 export function getLsyRefundPage(params: {
   pageNo: number;

+ 6 - 1
frontend/src/views/module_payment/collection/merchant/index.vue

@@ -98,8 +98,13 @@
 </template>
 
 <script setup lang="ts">
+// ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
+//   而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
+//   不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
+//   ★ 本名取自**活库** sys_menu.route_name(id=271,2026-09-23 只读实测;读数记在
+//     `java/src/main/resources/db/lsy-cashier-menu.sql` 头部)。要改就改页面,别改库。
 defineOptions({
-  name: "LeshouyingMerchant",
+  name: "CollectionMerchant",
   inheritAttrs: false,
 });
 

+ 145 - 3
frontend/src/views/module_payment/collection/order/index.vue

@@ -101,6 +101,14 @@
         <el-descriptions-item label="金额">
           {{ fen2yuan(detail.total_amount) }} 元 / 实收 {{ fen2yuan(detail.receipt_amount) }} 元
         </el-descriptions-item>
+        <!--
+          ★ 已退金额**本来就在详情响应里**(`LsyOrderEntity.refundedAmount`),只是此前没人渲染它:
+            状态列写着「已全额退款」而详情里一个字都看不到退款,用户 2026-09-29 报的正是这个。
+            这一行不需要后端改动 —— 缺的是显示。
+        -->
+        <el-descriptions-item label="已退金额">
+          {{ fen2yuan(detail.refunded_amount) }} 元
+        </el-descriptions-item>
         <el-descriptions-item label="支付结果">
           {{ detail.upstream_result_code }} {{ detail.upstream_result_msg }}
         </el-descriptions-item>
@@ -115,6 +123,51 @@
         <el-descriptions-item label="备注">{{ detail.description || "-" }}</el-descriptions-item>
       </el-descriptions>
 
+      <!--
+        退款单只在**有**的时候出现。空表会让每一笔正常支付都拖一节「没有退款」的空架子,
+        而这一节存在的意义恰恰是回答「这笔到底退没退、退了哪几笔」。
+      -->
+      <template v-if="refunds.length">
+        <h4 style="margin-top: 20px">退款单</h4>
+        <el-table :data="refunds" border size="small">
+          <el-table-column
+            prop="out_refund_no"
+            label="商户退款单号"
+            min-width="150"
+            show-overflow-tooltip
+          />
+          <el-table-column label="金额(元)" width="90" align="right">
+            <template #default="{ row }">{{ fen2yuan(row.refund_fee) }}</template>
+          </el-table-column>
+          <el-table-column label="状态" width="90">
+            <template #default="{ row }">
+              <el-tag :type="refundTagType(row.status)" size="small">
+                {{ refundStatusLabel(row.status) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <!--
+            上游退款单号可能是空的:它只在**同步应答**里才学到(预插入的那一行还没拿到)。
+            空串在这里会看起来像「退款单号丢了」,所以显式渲染成 -。
+          -->
+          <el-table-column label="退款单号" min-width="160" show-overflow-tooltip>
+            <template #default="{ row }">{{ row.refund_no || "-" }}</template>
+          </el-table-column>
+          <!--
+            宽度 180 与同抽屉「回调轨迹」的时间列、以及上方订单列表的「创建时间」一致 ——
+            这三处显示的是同一种值,宽度不该各写各的。
+            ★ 值的形态是**实测**的,不是估的:HTTP 层实收
+              `2026-09-29T11:04:13.007374+08:00`(**微秒** 6 位,共 32 字符)。
+              按「毫秒 3 位」估会少算 3 个字符、窄列里静默截断。
+              所以这里挂 show-overflow-tooltip:截断至少能悬停看全,不是无声丢字。
+            本页**不**引入 dayjs 格式化 —— 同一个抽屉里两节时间长得不一样,比原样显示更难读。
+          -->
+          <el-table-column label="完成时间" width="180" show-overflow-tooltip>
+            <template #default="{ row }">{{ row.finish_time || "-" }}</template>
+          </el-table-column>
+        </el-table>
+      </template>
+
       <h4 style="margin-top: 20px">回调轨迹</h4>
       <el-table :data="notifyLogs" border size="small">
         <el-table-column prop="created_time" label="时间" width="180" />
@@ -124,11 +177,18 @@
         <el-table-column prop="verify_status" label="验签" width="80">
           <template #default="{ row }">
             <el-tag :type="row.verify_status === 'SUCCESS' ? 'success' : 'danger'" size="small">
-              {{ row.verify_status }}
+              {{ verifyLabel(row.verify_status) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="process_status" label="处理" width="130">
+          <template #default="{ row }">
+            <el-tag v-if="isProcessAlert(row.process_status)" type="danger" size="small">
+              {{ processLabel(row.process_status) }}
             </el-tag>
+            <span v-else>{{ processLabel(row.process_status) }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="process_status" label="处理" width="130" />
         <el-table-column label="原文" min-width="200">
           <template #default="{ row }">
             <el-popover placement="left" :width="600" trigger="click">
@@ -148,8 +208,10 @@
 // ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
 //   而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
 //   不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
+//   ★ 本名取自**活库** sys_menu.route_name(id=269,2026-09-23 只读实测;读数记在
+//     `java/src/main/resources/db/lsy-cashier-menu.sql` 头部)。要改就改页面,别改库。
 defineOptions({
-  name: "LeshouyingOrder",
+  name: "CollectionOrder",
   inheritAttrs: false,
 });
 
@@ -158,6 +220,7 @@ import {
   getLsyOrderPage,
   getLsyOrderDetail,
   getLsyNotifyLogs,
+  getLsyOrderRefunds,
 } from "@/api/module_payment/collection";
 
 const STATUS_OPTIONS = [
@@ -178,6 +241,44 @@ const SCENE_LABEL: Record<string, string> = {
   H5: "H5",
 };
 
+/**
+ * `pay_lsy_notify_log.process_status` 的取值 → 中文。
+ *
+ * ★ 这 10 个词是**从 `LsyNotifyService` 全部 `logNotify(...)` 调用点逐个点出来的**,
+ *   并与库里的列注释(`db/lsy-notify-process-status-comment.sql` 那句 10 词表)对过,
+ *   两边一致。后端加词时这里必须同步 —— 漏一个就会让那一行退回显示英文(靠 `|| v` 兜底),
+ *   不报错、只是看不懂。
+ */
+const PROCESS_LABEL: Record<string, string> = {
+  RECEIVED: "已收到",
+  APPLIED: "已回写",
+  DUPLICATE: "重复回调",
+  REJECTED: "验签失败",
+  ORDER_NOT_FOUND: "订单不存在",
+  REFUND_NOT_FOUND: "退款单不存在",
+  UNKNOWN_RESULT_CODE: "未知结果码",
+  LATE_PAYMENT_AFTER_TERMINAL: "终态后到账",
+  LATE_REFUND_AFTER_TERMINAL: "终态后退款",
+  HELD_FOR_RECONCILE: "扣住待对账",
+};
+
+const VERIFY_LABEL: Record<string, string> = {
+  SUCCESS: "通过",
+  FAIL: "失败",
+};
+
+/**
+ * `LsyRefundStatus` 的四个取值 → 中文。**逐个取自枚举源码**(`CREATED / REFUNDING / SUCCESS / FAIL`,
+ * 见 `LsyRefundStatus.java` 的枚举体),措辞与退款列表页 `collection/refund/index.vue` 的下拉一致 ——
+ * 同一份状态在两个页面叫两个名字,排查时会对不上。
+ */
+const REFUND_STATUS_LABEL: Record<string, string> = {
+  CREATED: "已建单",
+  REFUNDING: "退款中",
+  SUCCESS: "退款成功",
+  FAIL: "退款失败",
+};
+
 /** 查询参数用 camelCase:后端 `@RequestParam` 未写 name,线上名即 `pageNo` / `pageSize` */
 const query = reactive({
   pageNo: 1,
@@ -192,6 +293,7 @@ const loading = ref(false);
 const detailVisible = ref(false);
 const detail = ref<any>(null);
 const notifyLogs = ref<any[]>([]);
+const refunds = ref<any[]>([]);
 
 function sceneLabel(v: string) {
   return SCENE_LABEL[v] || v;
@@ -212,6 +314,37 @@ function statusTagType(v: string) {
 function tradeTypeLabel(v: string) {
   return ({ "0": "退款", "1": "交易", "2": "云订单关单" } as Record<string, string>)[v] || v || "-";
 }
+function processLabel(v: string) {
+  return PROCESS_LABEL[v] || v || "-";
+}
+function verifyLabel(v: string) {
+  return VERIFY_LABEL[v] || v || "-";
+}
+function refundStatusLabel(v: string) {
+  return REFUND_STATUS_LABEL[v] || v || "-";
+}
+/** 在途用 warning、失败用 danger —— 与退款列表页同一套配色,避免同一状态两种颜色。 */
+function refundTagType(v: string) {
+  if (v === "SUCCESS") return "success";
+  if (v === "FAIL") return "danger";
+  return "warning";
+}
+/**
+ * 「处理」列里**需要人看一眼**的四类,标红。
+ *
+ * ★ `DUPLICATE` **不在**其中,这是刻意的:良性重复回调是幂等正常工作的证据,
+ *   标红会把真正的告警淹掉。后端 `LsyNotifyService` 特意把「良性重复」与
+ *   「账实不符」(LATE_*_AFTER_TERMINAL)拆成两个词,就是为了这处能分开看;
+ *   在界面上把它们渲染成一模一样的黑字,等于把那处区分又抹掉了。
+ */
+function isProcessAlert(v: string) {
+  return (
+    v === "LATE_PAYMENT_AFTER_TERMINAL" ||
+    v === "LATE_REFUND_AFTER_TERMINAL" ||
+    v === "HELD_FOR_RECONCILE" ||
+    v === "UNKNOWN_RESULT_CODE"
+  );
+}
 
 async function loadData() {
   loading.value = true;
@@ -241,6 +374,13 @@ function handleReset() {
 }
 
 async function openDetail(row: any) {
+  // ★ 先清空、再拉取:抽屉实例是**被复用**的,三条请求里任何一条抛异常,
+  //   上一次的内容就会原样留在界面上 —— 而它看起来完全像「这一笔订单的数据」。
+  //   (清空放在最前,所以下面三条赋值不再需要各自的兜底 || []。)
+  detail.value = null;
+  notifyLogs.value = [];
+  refunds.value = [];
+
   const res: any = await getLsyOrderDetail(row.out_trade_no);
   // ★ 两层 data:`request.ts` 的响应拦截器 `return response`,**不做解包**,
   //   所以 res.data 是整个 `{code,msg,data}` 信封,业务数据在 res.data.data。
@@ -249,6 +389,8 @@ async function openDetail(row: any) {
   detail.value = res.data.data;
   const logs: any = await getLsyNotifyLogs(row.out_trade_no);
   notifyLogs.value = logs.data.data || [];
+  const rfs: any = await getLsyOrderRefunds(row.out_trade_no);
+  refunds.value = rfs.data.data || [];
   detailVisible.value = true;
 }
 

+ 3 - 1
frontend/src/views/module_payment/collection/refund/index.vue

@@ -83,8 +83,10 @@
 // ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
 //   而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
 //   不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
+//   ★ 本名取自**活库** sys_menu.route_name(id=270,2026-09-23 只读实测;读数记在
+//     `java/src/main/resources/db/lsy-cashier-menu.sql` 头部)。要改就改页面,别改库。
 defineOptions({
-  name: "LeshouyingRefund",
+  name: "CollectionRefund",
   inheritAttrs: false,
 });
 

+ 33 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java

@@ -72,6 +72,30 @@ public class LeshouyingProperties {
     @Value("${spring.profiles.active:}")
     private String activeProfiles;
 
+    /**
+     * 通道总开关。**默认开。**
+     *
+     * <p>置 false 时:{@link #validate()} 的两道闸一并放行(通道不起用,配置对不对就不重要了),
+     * 且本模块的**全部对外入口**(7 个控制器)与**全部定时任务**(2 个调度器)都不装配
+     * (`@ConditionalOnProperty`,见各自的类注解)。关掉之后本模块不产生任何出站流量,
+     * 端点直接 404。
+     *
+     * <p>★ <b>默认值必须是 true —— 它就是防线本身。</b>默认 false 的话,
+     * {@code validate()} 在非 dev 环境下**永远不会执行**,那正是「用一个配置把闸整个绕过去」,
+     * 与 `SPRING_PROFILES_ACTIVE=prod,dev` 那个漏洞同形(后者见 {@link #isDevProfile()})。
+     * 所以「忘设」必须仍走闸,只有**显式**写 false 才放行。有两条测试钉着这个默认值:
+     * {@code LeshouyingPropertiesStartupTest} 的臂一(不带 enabled 仍必须炸)与
+     * {@code LeshouyingChannelSwitchWiringTest} 的默认臂(不带 enabled 仍必须装配)。
+     *
+     * <p>★ <b>它存在的理由</b>:第一次上机(2026-09-28)时通道**没有真实凭据**
+     * (文档测试值是 application.yml 的默认值),于是第二道闸把整个应用挡在生产门外 ——
+     * 而它本可以只是不干活。这个开关就是那条「不起用、也别挡路」的出口。
+     *
+     * <p>它不是 {@code missingOnboardingCredentials()} 那类进件参数:那类「没配就没法进件」,
+     * 本项「关掉就整个模块不参与」,粒度不同,故不进 {@link #missingOnboardingCredentials()}。
+     */
+    private boolean enabled = true;
+
     /** 被扫同步轮询次数(用户定:3 次) */
     private int syncPollTimes = 3;
 
@@ -138,9 +162,18 @@ public class LeshouyingProperties {
      * 键名打错也只是退回默认值,不会造成状态不收敛。
      *
      * 一次性列出**所有**缺失项:逐个抛会让人改一个重启一次,进件当天每轮重启都是分钟级。
+     *
+     * <p>★ <b>总开关关掉时两道闸都不跑</b>({@link #enabled},理由见其 javadoc):
+     * 通道不起用,配置对不对就不重要了。反过来,**开关开着**时这里仍然是 fail-closed ——
+     * 忘设环境变量不会走到「放行」,只会走到「起不来」。
      */
     @PostConstruct
     void validate() {
+        if (!enabled) {
+            log.warn("★ 乐收赢通道已由 leshouying.enabled=false 关闭:本模块的控制器与定时任务都不装配,"
+                    + "配置缺失 / 文档测试配置两道闸一并放行。要恢复通道,置回 true 并注入真实进件参数。");
+            return;
+        }
         List<String> missing = new ArrayList<>();
         if (StrUtil.isBlank(instNo)) {
             missing.add("leshouying.inst-no(环境变量 LESHOUYING_INST_NO)");

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingCashierController.java

@@ -25,6 +25,7 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
@@ -113,6 +114,8 @@ import java.util.Map;
  * {@code LeshouyingAdminWiringTest} 对撞(本类已登记进它的 {@code ADMIN_CONTROLLERS})。
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/cashier")
 @RequiredArgsConstructor

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantController.java

@@ -5,6 +5,7 @@ import com.payment.platform.common.response.Result;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
 import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
@@ -15,6 +16,8 @@ import org.springframework.web.bind.annotation.*;
  * <p>权限串与 Task 18 的 sys_menu.permission 是**跨任务契约**,须逐字一致
  * (PermissionCheck.hasAny 是全等匹配,差一个字符即 403)。
  */
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/merchant")
 @RequiredArgsConstructor

+ 5 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingNotifyController.java

@@ -5,6 +5,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyOnboardingNotif
 import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -34,6 +35,10 @@ import java.util.Map;
  * (CWE-117:{@code \r\n} 能伪造出一整行日志),且这几行日志<b>刻意打在验签之前</b>。
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配 —— 回调入口一并消失。
+// 这不只是「省事」:验签用的密钥在 prod 上曾等于文档公开的那个测试密钥,留着入口等于留着一个
+// 「谁都能签得出来」的伪造回调通道(见 LeshouyingProperties#enabled)。
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/notify")
 @RequiredArgsConstructor

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -20,6 +20,7 @@ import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
@@ -104,6 +105,8 @@ import java.util.Set;
  * 一个都测不出写错的权限串)。
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/onboarding")
 @RequiredArgsConstructor

+ 62 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.java

@@ -8,12 +8,16 @@ import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
 import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
 import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
 import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -29,6 +33,8 @@ import java.util.List;
  * <p>权限串与 Task 18 的 sys_menu.permission 是**跨任务契约**,须逐字一致 ——
  * 上面那个测试同时会拿 {@code db/lsy-menu.sql} 里的 permission 清单比对注解里的串。
  */
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/order")
 @RequiredArgsConstructor
@@ -36,6 +42,7 @@ public class LeshouyingOrderController {
 
     private final LsyOrderMapper orderMapper;
     private final LsyNotifyLogMapper notifyLogMapper;
+    private final LsyRefundMapper refundMapper;
 
     @PreAuthorize("@perm.hasAny('module_payment:leshouying:order:list')")
     @GetMapping("/page")
@@ -82,18 +89,72 @@ public class LeshouyingOrderController {
      * ★ 这里也修掉一个静默失效:原写法把 `outTradeNo` 这个 `@PathVariable` **完全没用上**
      *   (只用了可选的 `payTraceNo`),于是 `/notify-logs` 对任何订单都返回同样的
      *   「本租户最近 200 条」—— 路径参数被无声忽略,前端却会以为拿到的是这笔单的轨迹。
+     *
+     * ★★ 2026-09-29:查的是 trace 的**集合**,不再只是订单自己的那一个。
+     *   退款回调的审计行绑的是**那笔退款的 trace**(上游退款报文里的 `pay_trace_no`
+     *   就是我们发出去的 `refund_trace_no`,见 {@code LsyNotifyService#handleRefundNotify}),
+     *   所以只按订单 trace 查时,退款回调行**结构上**不可能出现 —— 用户实测到的
+     *   「已全额退款,回调轨迹还是支付的那四条」就是这个原因,不是数据没落库。
      */
     @PreAuthorize("@perm.hasAny('module_payment:leshouying:order:list')")
     @GetMapping("/{outTradeNo}/notify-logs")
     public Result<List<LsyNotifyLogEntity>> notifyLogs(@PathVariable String outTradeNo) {
         LsyOrderEntity order = requireOrder(outTradeNo);
 
+        List<String> traces = new ArrayList<>();
+        if (order.getPayTraceNo() != null) {
+            traces.add(order.getPayTraceNo());
+        }
+        for (LsyRefundEntity r : refundsOf(order)) {
+            // 退款行的 refund_trace_no 建表即 NOT NULL 且唯一(V1.8),但这里是防御性过滤:
+            // 一个 null 进 IN 列表会变成 `IN (..., NULL)` —— 它不会报错,只会静默地永不匹配,
+            // 而「静默不匹配」正是本条要修的那类缺陷。
+            if (r.getRefundTraceNo() != null) {
+                traces.add(r.getRefundTraceNo());
+            }
+        }
+        if (traces.isEmpty()) {
+            // 无键可查时**不发查询**,直接空集:`IN ()` 不是合法 SQL,
+            // 而「发一个恒空的谓词」只会让调用方以为查过。
+            return Result.ok(List.of());
+        }
+
         LambdaQueryWrapper<LsyNotifyLogEntity> w = new LambdaQueryWrapper<>();
-        w.eq(LsyNotifyLogEntity::getPayTraceNo, order.getPayTraceNo());
+        w.in(LsyNotifyLogEntity::getPayTraceNo, traces);
         w.orderByDesc(LsyNotifyLogEntity::getCreatedTime).last("LIMIT 200");
         return Result.ok(notifyLogMapper.selectList(w));
     }
 
+    /**
+     * 本单的退款单列表 —— 订单抽屉的「退款」一节用它。
+     *
+     * <p>【为什么不是复用 {@code /payment/collection/refund/page?keyword=}}
+     * 那条腿是 `LIKE out_trade_no`:拿到的是「订单号**包含**本单号」的退款单,
+     * 而抽屉要的是「**属于**本单的」退款单。两者在订单号互为前缀时会给出不同的行,
+     * 且调用方无法在客户端把它收敛回去(page 接口没有等值过滤)。
+     *
+     * <p>【权限串为什么复用 `order:list` 而不新开一个】返回的是**本订单自己的**退款单,
+     * 而订单实体里本来就带着 `refunded_amount` —— 能看到这个订单的人已经看得到
+     * 「它被退过多少钱」。新开权限串反而会让抽屉对一部分人半残(余额看得见、明细看不见),
+     * 且要多一份菜单 SQL。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:order:list')")
+    @GetMapping("/{outTradeNo}/refunds")
+    public Result<List<LsyRefundEntity>> refunds(@PathVariable String outTradeNo) {
+        LsyOrderEntity order = requireOrder(outTradeNo);
+        return Result.ok(refundsOf(order));
+    }
+
+    /**
+     * 取本单的退款单。**先经 {@link #requireOrder}** 再过这里 —— 租户边界建在订单上,
+     * 与 {@code notifyLogs} 同一理由(见那边的 javadoc)。
+     */
+    private List<LsyRefundEntity> refundsOf(LsyOrderEntity order) {
+        return refundMapper.selectList(new LambdaQueryWrapper<LsyRefundEntity>()
+                .eq(LsyRefundEntity::getOutTradeNo, order.getOutTradeNo())
+                .orderByDesc(LsyRefundEntity::getCreatedTime));
+    }
+
     /**
      * 取本租户可见的订单,取不到一律 404。
      * ★ 与 Task 15 的 OpenAPI 侧同一条纪律:**绝不把「不存在」和「不属于你」区分开** ——

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingRefundController.java

@@ -8,6 +8,7 @@ import com.payment.platform.common.response.Result;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
 import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
 import lombok.RequiredArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
@@ -17,6 +18,8 @@ import org.springframework.web.bind.annotation.*;
  *
  * <p>权限串与 Task 18 的 sys_menu.permission 是**跨任务契约**,须逐字一致。
  */
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/collection/refund")
 @RequiredArgsConstructor

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java

@@ -21,6 +21,7 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.converter.HttpMessageNotReadableException;
 import org.springframework.web.bind.annotation.*;
@@ -42,6 +43,8 @@ import java.util.function.Supplier;
  * 租户身份从 request attribute 取(由 filter 注入),不信任请求体里的 tenant_id。
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @RestController
 @RequestMapping("/payment/openapi/pay")
 @RequiredArgsConstructor

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyOrderPollScheduler.java

@@ -11,6 +11,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
 import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -101,6 +102,8 @@ import java.util.Map;
  *   清册在 `LsyReconcileScheduler` 的类 javadoc 里,以那处为准。)
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配 ⇒ 一次出站轮询都不会发生(见 LeshouyingProperties#enabled)
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @Component
 @RequiredArgsConstructor
 public class LsyOrderPollScheduler {

+ 74 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.leshouying.scheduler;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
@@ -16,6 +17,7 @@ import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
 import com.payment.platform.module.payment.leshouying.service.LsyRefundService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
@@ -105,6 +107,8 @@ import java.util.Map;
  *   ⇒ 「退款推送失败会被重试」这句话**现在成立**(此前不成立,本条是那次修复后的订正)。
  */
 @Slf4j
+// 总开关:leshouying.enabled=false 时本类不装配 ⇒ 一次出站对账都不会发生(见 LeshouyingProperties#enabled)
+@ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
 @Component
 @RequiredArgsConstructor
 public class LsyReconcileScheduler {
@@ -118,6 +122,51 @@ public class LsyReconcileScheduler {
     private final LsyRefundService refundService;
     private final LeshouyingProperties props;
 
+    /**
+     * 「上游说它那儿没有这笔单/这笔退款」的原文片段。
+     *
+     * <p>判据是**这一句原文**,不是异常类型 —— 见 {@link #upstreamSaysNotFound} 的失败方向说明。
+     */
+    private static final String NOT_FOUND_MSG = "订单信息不存在";
+
+    /**
+     * 这个异常是不是「上游查无此单」这种**正常业务回答**(是则返回 true,调用方降成 WARN)。
+     *
+     * <p>【为什么需要它】{@code LeshouyingResponseParser.parseV3} 对
+     * {@code return_code != SUCCESS} 一律**抛异常**(那是出站腿要的语义:通信层失败必须炸,
+     * 别把「没连上」当成「付款失败了」)。但上游把**业务性的「查无此单」也放在
+     * {@code return_code=FAIL}` 里回 —— 于是这一跳穿过 service 落进下面两个 catch,
+     * 被记成**带完整栈的 ERROR**。
+     *
+     * <p>而这个状态是**正常的、会自愈的**:
+     * <ul>
+     *   <li>订单腿:**未扫的聚合码单**在 {@code /v3/queryorder} 上回的就是这句话,
+     *       实测该状态与关单动作无关(差分对照:同刻两笔同参聚合码单,在关单前后都查不到),
+     *       读数 {@code logs/_lsy-close-control.txt}。每 5 分钟一条 ERROR,
+     *       直到 {@link #expireStaleOrders} 把它收走。</li>
+     *   <li>退款腿:{@code LsyRefundService.createRefund} 把退款行**预插入**成 CREATED,
+     *       那一刻的语义就是「我们记下了,但**还不知道上游有没有收到**」——
+     *       预插入之后 POST 超时/连接重置的行,上游根本没见过这笔退款。</li>
+     * </ul>
+     * 栈在这里零信息量(每次都是同一条 {@code postForRawJson → parseV3 → throw}),
+     * 而一条恒假的 ERROR 会训练运维忽略 ERROR —— 真正的故障就被它淹掉了。
+     *
+     * <p>【失败方向:故意不对称】判据是「上游原文**包含**某句话」而不是
+     * 「凡是 {@link LeshouyingApiException} 就降级」:
+     * 上游改了措辞 ⇒ 我们退回 <b>noisy</b>(ERROR 带栈),**不会**退回 silent。
+     * 宁可再吵一次,也不要静默掉「验签失败」「商户未配置」这类**每笔都会失败**的真故障。
+     * 这条不对称由 {@code LsySchedulerTest} 的两条反向守卫钉住(无条件降级 ⇒ 它们必须红)。
+     *
+     * <p>【用哪个 getter】判据读 {@code getRawReturnMsg()}(上游原文,未截断);
+     * 写日志一律用 {@code getMessage()}(已 sanitize + 封顶,见 {@link LeshouyingApiException})。
+     * 两者不可互换 —— 直接把 raw 文本写进日志是 CWE-117。
+     */
+    private static boolean upstreamSaysNotFound(Throwable e) {
+        return e instanceof LeshouyingApiException api
+                && api.getRawReturnMsg() != null
+                && api.getRawReturnMsg().contains(NOT_FOUND_MSG);
+    }
+
     @Scheduled(fixedDelay = 5 * 60 * 1000)
     public void reconcile() {
         try {
@@ -168,7 +217,15 @@ public class LsyReconcileScheduler {
                     callbackService.notifyOrder(order);
                 }
             } catch (Exception e) {
-                log.error("对账查询异常: outTradeNo={}", order.getOutTradeNo(), e);
+                // ★ 上游「查无此单」是**正常业务回答**(未扫的聚合码单恒回这句),降成不带栈的 WARN。
+                //   失败方向见 upstreamSaysNotFound 的 javadoc:措辞变了就退回吵闹的 ERROR。
+                //   级别是这里唯一变的东西 —— 订单状态一个字都不动,下面什么都不做正是原行为。
+                if (upstreamSaysNotFound(e)) {
+                    log.warn("对账查询:上游暂无此单,留待下轮: outTradeNo={}, upstream={}",
+                            order.getOutTradeNo(), e.getMessage());
+                } else {
+                    log.error("对账查询异常: outTradeNo={}", order.getOutTradeNo(), e);
+                }
             }
         }
     }
@@ -219,7 +276,14 @@ public class LsyReconcileScheduler {
                             refund.getOutRefundNo(), settled);
                 }
             } catch (Exception e) {
-                log.error("退款对账查询异常: outRefundNo={}", refund.getOutRefundNo(), e);
+                // ★ 同订单腿:预插入成 CREATED 的退款行,上游「没见过这笔」是设计内状态
+                //   (见 upstreamSaysNotFound 的 javadoc),降成不带栈的 WARN。
+                if (upstreamSaysNotFound(e)) {
+                    log.warn("退款对账:上游暂无此退款,留待下轮: outRefundNo={}, upstream={}",
+                            refund.getOutRefundNo(), e.getMessage());
+                } else {
+                    log.error("退款对账查询异常: outRefundNo={}", refund.getOutRefundNo(), e);
+                }
             }
             // ★ 三种情形都必须推:
             //   (a) 未收敛(上游回 PAY_IN_PROCESS / 未知码)—— 这类行**一次库都不写**,
@@ -265,6 +329,14 @@ public class LsyReconcileScheduler {
             try {
                 applyCloseResult(order);
             } catch (Exception e) {
+                // 【这条腿为什么**不**跟着降级 —— 有读数,但没到判据】
+                //   同一个探针实测:拿一个上游不认的 code_url 打 /v3/qrclose,回
+                //   return_code=FAIL +「该聚合码链接不存在或已过期!」,也会抛异常走到这里
+                //   (原始字节 logs/_lsy-lc4/qrclose-bogus-url.bin)。
+                //   形状与上面两条腿一样,但**形状不是判据**:本腿的扫描条件是
+                //   「过期 + 仍 PAYING + code_url 非空」,而 code_url 是 qrpay **成功**之后
+                //   才落库的 ⇒ 上游不认这个 code_url 属反常,不是设计内状态。
+                //   我没有它复发的读数,所以维持 ERROR + 栈不变。
                 log.error("聚合码关单异常: outTradeNo={}", order.getOutTradeNo(), e);
             }
         }

+ 91 - 15
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCancelService.java

@@ -44,10 +44,7 @@ public class LsyCancelService {
     public String closeOrder(LsyOrderEntity order) {
         String scene = order.getPayScene();
         if ("H5".equals(scene)) {
-            // 上游 H5 目录下无独立关单接口,closeorder 正文也未提 H5。
-            // v1 只做本地收敛,不动上游(见 spec §2.3 / §14 待确认项)。
-            log.info("H5 订单无已知关单接口,本地收敛为 EXPIRED: outTradeNo={}", order.getOutTradeNo());
-            return "EXPIRED";
+            return closeH5Order(order);
         }
         if ("QRCODE".equals(scene)) {
             return closeQrOrder(order);
@@ -55,6 +52,72 @@ public class LsyCancelService {
         return cancelBarcodeOrder(order);
     }
 
+    /**
+     * H5 关单:打上游 {@code /v3/closeorder}({@code trade_no} 寻址)。
+     *
+     * <p>★ <b>2026-09-28 联调改判 —— 这里原先是「不动上游、本地收敛为 EXPIRED」。</b>
+     * 原依据是「上游 H5 目录下无独立关单接口」。那句话**字面仍真**
+     * (142 页文档里标题含「关单」的只有三页:196236529 {@code /v3/closeorder}、
+     * 196236547 {@code /v3/qrclose}、196236550 云订单 {@code /v3/trade/closePayTrade},
+     * H5 目录下确实一页都没有),但由它推出的结论是**假的**:
+     * {@code /v3/closeorder} 落在「支付接口」目录下,正文只写了可供「二维码支付,JSAPI支付」,
+     * <b>实测对 H5 一样受理</b>。
+     *
+     * <p>取证不是「关单成功」四个字(那只能证明它应了一声),而是**差分对照**:
+     * 同一时刻建两笔同参 H5,只关其中一笔 ——
+     * 被关的 A {@code PAY_IN_PROCESS → PAY_FAIL},没动的 B {@code PAY_IN_PROCESS → PAY_IN_PROCESS}。
+     * 读数在 {@code logs/_lsy-close-control.txt}。
+     *
+     * <p>【为什么这件事必须改】只本地收敛 = 关单按钮对操作员**说谎**:
+     * 页面上写「已撤销」,而上游那条支付链接**还活着**,顾客随时能付。
+     * 顾客真付了的话,{@code EXPIRED} 不在 {@code LsyOrderStatus.PAY_WRITE_FROM} 里,
+     * {@code applyPayResult} 命中 0 行,只能由 {@code LsyNotifyService} 报一条
+     * LATE_PAYMENT_AFTER_TERMINAL —— 钱收了、单是「已过期」,账实不符它**能被发现,但已经发生了**。
+     * 打了上游这一跳,这件事根本不会发生。
+     *
+     * <p>【本地收敛没删】上游没给过订单号(下单那一跳就失败了)时**没有句柄可关**,
+     * 仍旧只能本地收敛 —— 见下面的空判分支。
+     *
+     * <p>【本方法**故意**不先查一次】与 {@link #cancelBarcodeOrder} 的「先查后撤」不同:
+     * 那边的查询是用来分辨「撤销会不会变成退款」的,撤销一旦撤到已付款的单,
+     * 上游会把钱退给用户(196236528 撤销接口正文明写),属**资金动作**,必须查。
+     * 而 196236529 关单接口正文没有这层语义,实测对一笔 PAY_FAIL 的单它直接拒绝
+     * (「关单失败:该订单交易失败,不支持关单」)。反过来,H5 单在顾客还没付款时的
+     * **正常态就是 {@code PAY_IN_PROCESS}**(实测四次全是「用户支付中」)——
+     * 照抄「先查后撤」会让这一支在最该关单的那一刻返回 null,按钮永远点不动,
+     * 比原来那个假的「本地收敛」还糟。
+     *
+     * <p>【仍未测到的】顾客**正在**付款的那一瞬间调关单会怎样(可能两边同时进行)。
+     * 上游与我都无法在无真实付款人的环境下造出这个时刻。本地这一侧有兜底:
+     * 控制器落库是 {@code in("status", LsyOrderStatus.PENDING)} 的条件更新,
+     * 回调若已抢先写 SUCCESS,这次更新命中 0 行,操作员会看到「这一单的状态刚刚变了」。
+     */
+    private String closeH5Order(LsyOrderEntity order) {
+        if (order.getTradeNo() == null || order.getTradeNo().isBlank()) {
+            log.warn("H5 订单没有上游订单号(下单未成功),无句柄可关,本地收敛: outTradeNo={}",
+                    order.getOutTradeNo());
+            return "EXPIRED";
+        }
+        // 入参逐字照 196236529 的请求参数表:只发 mch_no / close_trace_no / close_time / trade_no,
+        // 不发 shop_no(该表里没有这一项)。这套形状就是联调实测被受理的那一套。
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("mch_no", order.getMchNo());
+        p.put("close_trace_no", IdUtil.fastSimpleUUID());
+        p.put("close_time", LocalDateTime.now().format(FMT));
+        p.put("trade_no", order.getTradeNo());
+
+        Map<String, Object> resp = client.post("/v3/closeorder", p);
+        if (parser.isPaySuccess(resp)) {
+            return "CLOSED";
+        }
+        // 实测这一支的原文是「关单失败:该订单交易失败,不支持关单」(对已关/已失败的单)。
+        // 【两个上游实参过 LsyLogSanitizer.flatten】同 closeQrOrder:报文原文不经 DTO 校验。
+        log.warn("H5 关单失败: outTradeNo={}, result_code={}, return_msg={}",
+                order.getOutTradeNo(), LsyLogSanitizer.flatten(resp.get("result_code")),
+                LsyLogSanitizer.flatten(resp.get("return_msg")));
+        return null;
+    }
+
     /** 聚合码关单:入参是 code_url,不是 trade_no */
     private String closeQrOrder(LsyOrderEntity order) {
         if (order.getCodeUrl() == null || order.getCodeUrl().isBlank()) {
@@ -73,14 +136,17 @@ public class LsyCancelService {
             return "CLOSED";
         }
         // ★ 必须把 result_code 一起打出来,不能只打 return_msg。
-        //   上游文档里 `/v3/qrclose`(以及 closeorder / cancelorder)**正文把成功写成 `PAY_SUCEESS`**
-        //   —— 少一个 S。若那不是笔误而是真实返回值,`isPaySuccess` 对它为 false,
-        //   于是关单**实际上成功了**,我们却在这里记一条「失败」,
-        //   而 `return_msg` 大概率是「成功」二字 —— 运维看到「关单失败: msg=成功」将无从判断。
-        //   带上 result_code,这一条日志自己就回答了「是不是那个拼写」。
-        //   ★ 不要为了这个去给 parser 加 `PAY_SUCEESS`:我们无法判定它是笔误还是真实值,
-        //     而两种误判不对称 —— 猜错成真实值会让一个可疑拼写进入正常路径;
-        //     猜成笔误只是让关单多走人工核对。**留到联调第一笔关单看日志定案。**
+        //
+        //   【那个 `PAY_SUCEESS` 拼写疑问 —— 2026-09-28 联调已定案:是**文档笔误**。】
+        //   文档有 5 页正文把成功写成 `PAY_SUCEESS`(少一个 S):关单 196236529、撤销 196236528、
+        //   云订单 196236548/196236549/196236550。而联调实测,五条关单应答回来的**全是
+        //   `PAY_SUCCESS`**(正确拼写):`/v3/qrclose` 与 `/v3/closeorder` 各两次成功、
+        //   外加一次 PAY_FAIL —— 一次 `PAY_SUCEESS` 都没出现过。读数见 `logs/_lsy-lifecycle.txt`、
+        //   `logs/_lsy-round3.txt`、`logs/_lsy-close-control.txt` 的原始 .bin。
+        //   ⇒ 所以 `isPaySuccess` 的判据是对的,**parser 什么都不用加**(当初「不要加」的决定是对的,
+        //     而且理由现在从「无法判定」升级成了「已实测不是」)。
+        //   result_code 仍留在日志里:它让这一条自己就能回答「是不是那个拼写」,
+        //   而这个价值不依赖那个疑问是否已解 —— 上游再冒出别的码时它照样是第一线索。
         //
         // 【两个上游实参过 LsyLogSanitizer.flatten】它们来自 HTTP 响应体,不经过任何 DTO 校验
         // (入口控制覆盖不到),必须在汇点压平(CWE-117)。
@@ -134,9 +200,19 @@ public class LsyCancelService {
         if (parser.isPaySuccess(resp)) {
             return "CANCELED";
         }
-        // ★ 同 closeQrOrder:`/v3/cancelorder` 的文档正文把成功写成 `PAY_SUCEESS`(少一个 S)。
-        //   不带 result_code 的话,「撤销实际成功、我们记成失败」将表现为
-        //   「撤销失败: outTradeNo=..., msg=成功」—— 一句自相矛盾、无法定位的日志。
+        // ★ 同 closeQrOrder:`/v3/cancelorder` 的文档正文把成功写成 `PAY_SUCEESS`(少一个 S)——
+        //   2026-09-28 联调已定案为**文档笔误**(该页正文那句的「参数示例」列自己写的就是
+        //   正确拼写 `PAY_SUCCESS`)。不带 result_code 的话,「撤销实际成功、我们记成失败」
+        //   将表现为「撤销失败: outTradeNo=..., msg=成功」—— 一句自相矛盾、无法定位的日志。
+        //
+        //   【这条腿在本商户上目前是死的 —— 但**不改**,见下】
+        //   实测四次(两笔真实被扫失败单 × trade_no / pay_trace_no+pay_time 两种寻址),
+        //   上游一律回 `return_code=FAIL` + 「暂不支持撤销!」(sign 有效,是我方收到的真应答)。
+        //   即:**本商户(测试环境 Y52211090004)没有开通撤销**。
+        //   不改的理由:这是**上游的商户配置**,不是我们的缺陷 —— 我们这边该发的都发了、
+        //   该守的都守了;它随时可能被上游开通。真要改善的只是**操作员看到的文案**
+        //   (现在他会看到控制器那句笼统的「撤销失败」,拿不到「暂不支持撤销」这个真实原因),
+        //   那是 `LeshouyingCashierController.close` 的文案问题,**属另一个决定,本批不动**。
         //
         // 【两个上游实参过 LsyLogSanitizer.flatten】同 closeQrOrder:报文原文不经 DTO 校验。
         // outTradeNo 不套:它由入口 @Pattern 保证不含控制字符。

+ 113 - 27
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyService.java

@@ -90,6 +90,18 @@ public class LsyNotifyService {
             logNotify(params, rawBody, null, "FAIL", "REJECTED", "验签失败");
             return BAD;
         }
+
+        // ★★ 分流:上游只认**一个**回调地址(下单时配的 notify_url),退款通知也推它,
+        //   而我们的两个端点是分开的。必须在**任何业务查询之前**判,理由是顺序而非洁癖:
+        //   放到「查订单 → 查不到」之后再分流,一次回调就会写两条 RECEIVED 审计行
+        //   (支付侧一条、退款侧一条),「回调轨迹」里凭空多一行。
+        //   2026-09-29 实弹实测到的正是本分流缺失时的形态:退款回调打在 /notify/pay 上,
+        //   拿**退款的** trace 去查订单(必然查不到),写一条 ORDER_NOT_FOUND 就结束了。
+        //   详见 handleRefundNotify 的定位键注释与 logs/_lsy-refund-callback-raw.json。
+        if (isRefundNotify(params)) {
+            return handleRefundNotify(params, rawBody);
+        }
+
         // 此刻还没查订单,归属未知 → tenantId 传 null(审计行只对超管可见,见 logNotify 的 javadoc)
         logNotify(params, rawBody, null, "SUCCESS", "RECEIVED", null);
 
@@ -199,36 +211,49 @@ public class LsyNotifyService {
         // 此刻还没查退款单,归属未知 → tenantId 传 null
         logNotify(params, rawBody, null, "SUCCESS", "RECEIVED", null);
 
-        // ⚠⚠ 实测:refund_no **不是**本回调的定位键,这一段现在恒走 null 分支。
-        //   上游「退款异步回调」页的**完整字段表(24 行)与请求样例里没有任何退款标识**
-        //   —— refund_no / out_refund_no / refund_trace_no / qd_refund_no 一个都没有;
-        //   支付回调页同构,证明不是漏列。⇒ str(params.get("refund_no")) 恒为 null。
+        // ★★ 定位键 = 报文的 `pay_trace_no`,落在 `refund_trace_no` 列上(2026-09-29 实弹报文裁定)。
+        //
+        //   本段此前写的是「定位键挂起,不猜」,依据是上游 opendocs「退款异步回调」全页
+        //   (24 行字段表 + 请求示例)里 refund_no / out_refund_no / refund_trace_no /
+        //   qd_refund_no **一个都没有**。那个读数是**真的**,但由它推出的结论——「报文里
+        //   没有任何退款标识」——**是错的**:文档没有定义 `pay_trace_no` 在退款回调里指哪一笔,
+        //   我当时把「文档没写」当成了「不存在」,于是把唯一可能的键也一起排除掉了。
+        //
+        //   2026-09-29 一笔真实退款(1 分)的回调给出 25 个字段的报文,其中
+        //     pay_trace_no = 4e8ceec2b83b44a78efaacd73499b5a9
+        //   与我们随退款请求发出去的 `refund_trace_no`(LsyRefundService 里
+        //   IdUtil.fastSimpleUUID() 生成)**逐字符相同** —— 是我们选的值、由上游原样回传,
+        //   因果闭合,不是同形巧合。报文原文见 logs/_lsy-refund-callback-raw.json。
+        //
         //   ⚠ 不要以为「null 就当没这个条件」:`.eq(col, null)` 在 MP 里**照发谓词**,
         //   绑一个真正的 null(实测 getSqlSegment / getParamNameValuePairs),
-        //   而 PG 里 `refund_no = NULL` 恒为 UNKNOWN ⇒ **恒 0 行** ⇒ refund == null 每次必中。
+        //   而 PG 里 `refund_no = NULL` 恒为 UNKNOWN ⇒ **恒 0 行**。
         //   (好在不是退化成无 WHERE 的 LIMIT 1 —— 那会随机捞一行,更糟。)
-        //   后果:本方法的其余部分(三分支词表 / 幂等闸门 / LATE_REFUND_AFTER_TERMINAL)
-        //   在真实流量下是**死区**,且每次回调都误写一条 REFUND_NOT_FOUND 审计行。
-        //   影响范围**不是**「钱动了我们不知道」:异步收敛由
-        //   LsyReconcileScheduler.reconcilePendingRefunds 兜住(它按 status + created_time 扫,
-        //   不依赖任何上游字段)。它只是从未被点亮 —— 库里 pay_lsy_refund 0 行、
-        //   notify_log 0 行,日志里 /v3/refundorder 也是 0 命中。
-        //   ⇒ 重新定位键**挂起,不猜**(本项目铁律)。定夺依据:真实报文
-        //   (raw_body 已逐条落库)或上游答复。详见
-        //   docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md
-        //   §一「★★ 2026-09-22 裁定」与其下的「📌 待订正清单」。
-        String refundNo = str(params.get("refund_no"));
-        LsyRefundEntity refund = refundMapper.selectOne(new LambdaQueryWrapper<LsyRefundEntity>()
-                .eq(LsyRefundEntity::getRefundNo, refundNo)
-                .last("LIMIT 1"));
+        //   ⇒ 每个键都只在**非 null 时**才发查询;两个都没有就一次库都不查。
+        //
+        //   兜底保留 `refund_no` 的理由:`pay_trace_no` 指「本笔退款」这一读法目前只有**一笔**
+        //   样本。若上游在别的场景改发原支付的 trace,主键会落空 —— 那时兜底救不回来
+        //   (真实报文里本就没有 refund_no),但**也不会更坏**:仍是 REFUND_NOT_FOUND。
+        //
+        //   影响面**不是**「钱动了我们不知道」:异步收敛由
+        //   LsyReconcileScheduler.reconcilePendingRefunds 兜住(按 status + created_time 扫,
+        //   不依赖任何上游字段)。本次改动是给那条从未通电的死腿接上线,不是补资金安全网。
+        //   原裁定全文见
+        //   docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md §一。
+        LsyRefundEntity refund = findByRefundTraceNo(str(params.get("pay_trace_no")));
+        if (refund == null) {
+            refund = findByRefundNo(str(params.get("refund_no")));
+        }
         if (refund == null) {
-            log.error("乐收赢退款回调找不到退款单: refundNo={}",
-                    LsyLogSanitizer.flatten(refundNo));
+            log.error("乐收赢退款回调找不到退款单: refundTraceNo={}, refundNo={}",
+                    LsyLogSanitizer.flatten(str(params.get("pay_trace_no"))),
+                    LsyLogSanitizer.flatten(str(params.get("refund_no"))));
             // 与支付侧 ORDER_NOT_FOUND 对齐,补上审计行:
             // 「上游说退款回调来了,我们这边没有这笔退款单」是最需要留痕的一类事件,
             // 只写 log.error 会随日志轮转消失,事后无从查证。
-            // ⚠ 但在当前缺陷下这条**不是**「上游说有一笔我们不认识的退款」,
-            //   而是「定位键恒 null」的必然结果 —— 排查时别把它的出现当成异常信号。
+            // ★ 2026-09-29 之后这条**恢复**成异常信号了:在此之前它每次回调必中
+            //   (定位键恒 null),所以当时的注释写着「别把它的出现当成异常」——
+            //   那句话随本次修复**一并作废**,出现即说明键对不上或我们没落库。
             logNotify(params, rawBody, null, "SUCCESS", "REFUND_NOT_FOUND", "退款单不存在");
             return OK;
         }
@@ -281,7 +306,7 @@ public class LsyNotifyService {
                             + "outRefundNo={}, refundNo={}, return_msg={}",
                     refund.getStatus(),
                     refund.getOutRefundNo(),
-                    LsyLogSanitizer.flatten(refundNo),
+                    LsyLogSanitizer.flatten(refund.getRefundNo()),
                     LsyLogSanitizer.flatten(params.get("return_msg")));
             // ★ 审计词是新造的**我们自己的**词,不是上游词汇 —— 「不新造上游词汇」那条约束
             //   针对的是 `result_code` 这类上游字段值(必须有文档实证),审计词本来就是我们的词表。
@@ -300,7 +325,7 @@ public class LsyNotifyService {
         if (outcome == null) {
             log.error("乐收赢退款回调返回未知 result_code,不落终态、留在 REFUNDING 待人工核对: "
                             + "refundNo={}, return_code={}, result_code={}, return_msg={}",
-                    LsyLogSanitizer.flatten(refundNo),
+                    LsyLogSanitizer.flatten(refund.getRefundNo()),
                     LsyLogSanitizer.flatten(params.get("return_code")),
                     LsyLogSanitizer.flatten(resultCode),
                     LsyLogSanitizer.flatten(params.get("return_msg")));
@@ -343,9 +368,9 @@ public class LsyNotifyService {
                 log.error("★ 账实不符:上游回调称退款成功,但本行已落终态 {},本次回写被拒;"
                                 + "钱可能已退给用户,需人工核对!outRefundNo={}, refundNo={}",
                         refund.getStatus(), refund.getOutRefundNo(),
-                        LsyLogSanitizer.flatten(refundNo));
+                        LsyLogSanitizer.flatten(refund.getRefundNo()));
             } else {
-                log.info("退款回调重复,忽略: refundNo={}", LsyLogSanitizer.flatten(refundNo));
+                log.info("退款回调重复,忽略: refundNo={}", LsyLogSanitizer.flatten(refund.getRefundNo()));
             }
             logNotify(params, rawBody, refund.getTenantId(), "SUCCESS",
                     lateRefundAfterTerminal ? "LATE_REFUND_AFTER_TERMINAL" : "DUPLICATE", null);
@@ -443,4 +468,65 @@ public class LsyNotifyService {
     private String str(Object o) {
         return o == null ? null : String.valueOf(o);
     }
+
+    /**
+     * 这笔回调是不是**退款**通知 —— 用于把打到支付端点上的退款通知分流给退款腿。
+     *
+     * <p>【为什么要分流】上游只认一个回调地址(下单时配的 `notify_url`),退款通知也推它;
+     * 我们的两个端点却是分开的。实测(2026-09-29):退款回调打在 `/notify/pay` 上,
+     * 支付腿拿退款的 trace 去查订单、必然查不到,写一条 `ORDER_NOT_FOUND` 就结束了。
+     *
+     * <p>【判据为什么取这两个,而不是「查不到订单就当退款」】后者是**从结果反推类别**:
+     * 一笔 trace 对不上的**支付**回调(我们没这笔单,或上游发错了)会被误判成退款,
+     * 于是最该报警的 `ORDER_NOT_FOUND` 反而写不出来。判据必须来自报文**自身的**语义:
+     *
+     * <ul>
+     *   <li>`result_code` 前缀 `TRADE_REFUND` —— 退款侧独有词表
+     *       (`TRADE_REFUND` / `TRADE_REFUND_FAIL`),支付侧是 `PAY_SUCCESS` / `PAY_FAIL`;</li>
+     *   <li>`trade_type = "0"` —— 我方设计文档 §4.2 记的上游枚举:0退款/1交易/2云订单关单,
+     *       与支付侧的 `"1"` 互斥。</li>
+     * </ul>
+     *
+     * <p>两条任一成立即分流。判错的最坏后果**有界**:分流到退款腿而退款腿也找不到单时,
+     * 写的是 `REFUND_NOT_FOUND` 审计行并应答成功 —— 不写任何业务状态、不丢钱,
+     * 与支付腿在同情形下写的 `ORDER_NOT_FOUND` 是同一量级的审计噪音。
+     * 反方向(漏判)的后果同样有界:退回今天的行为。
+     */
+    private boolean isRefundNotify(Map<String, Object> params) {
+        String resultCode = str(params.get("result_code"));
+        if (resultCode != null && resultCode.startsWith("TRADE_REFUND")) {
+            return true;
+        }
+        return "0".equals(str(params.get("trade_type")));
+    }
+
+    /**
+     * 按 `refund_trace_no` 定位退款单;键为 null 时**一次库都不查**(见 {@link #findByRefundNo})。
+     */
+    private LsyRefundEntity findByRefundTraceNo(String refundTraceNo) {
+        if (refundTraceNo == null) {
+            return null;
+        }
+        return refundMapper.selectOne(new LambdaQueryWrapper<LsyRefundEntity>()
+                .eq(LsyRefundEntity::getRefundTraceNo, refundTraceNo)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 兜底:按上游给的 `refund_no` 定位退款单。
+     *
+     * <p>**键为 null 时直接返回 null,不发查询。** `.eq(col, null)` 在 MP 里照发谓词、
+     * 绑一个真正的 null,而 PG 里 `refund_no = NULL` 恒为 UNKNOWN ⇒ 恒 0 行 ——
+     * 结果与不查完全一样,只是让日志里多一条看上去像「查过、没找到」的记录。
+     * 而「查过没有」与「没有可查的键」这两种情形的处置不同(前者要问上游是不是发了新字段,
+     * 后者要查我们自己的退款单为什么没落库),不能混成同一个读数。
+     */
+    private LsyRefundEntity findByRefundNo(String refundNo) {
+        if (refundNo == null) {
+            return null;
+        }
+        return refundMapper.selectOne(new LambdaQueryWrapper<LsyRefundEntity>()
+                .eq(LsyRefundEntity::getRefundNo, refundNo)
+                .last("LIMIT 1"));
+    }
 }

+ 77 - 16
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java

@@ -80,13 +80,33 @@ public class LsyOrderService {
             //
             // 【判据为什么挂在这里,而不是裁定字面写的 payPatch】裁定当时的措辞是
             //   「在 payPatch 里,当上游回了成功码而 trade_no 为空」。照字面放会**每笔主扫下单都误报**:
-            //   /v3/qrpay 与 /v3/h5pay 的 PAY_SUCCESS 描述的是「下单请求被受理」(完整机制见
-            //   createQrOrder 的注释),那两个路径传的 patch 本来就没有 trade_no,订单落 PAYING、
-            //   收款尚未发生 —— 没有 trade_no 是**正常**而非异常。响在这里就是告警疲劳,
+            //   /v3/qrpay 的 PAY_SUCCESS 描述的是「下单请求被受理」(完整机制见 createQrOrder 的注释),
+            //   该路径传的 patch 没有 trade_no(**实测** 2026-09-28:qrpay 的 PAY_SUCCESS 应答不含该字段),
+            //   订单落 PAYING、收款尚未发生 —— 没有 trade_no 是**正常**而非异常。响在这里就是告警疲劳,
             //   真告警会被淹掉,这项可见化等于白做。
-            //   【按调用点逐个量过,不是推断】全 main 里 isPaySuccess 为真的调用点**恰好 4 个**:
-            //   createQrOrder:220 / createH5Order:317 落 PAYING(都不该响),
-            //   settleFromQuery:414 与回调 LsyNotifyService 落 SUCCESS(都该响)。
+            //   ★【2026-09-28 订正】此处原写「/v3/qrpay 与 /v3/h5pay …那两个路径传的 patch 本来就没有
+            //     trade_no」—— **h5 那一半不实**:实测 /v3/h5pay 的 PAY_SUCCESS 应答**带 trade_no**
+            //     (200001070X4326092816045010002),payPatch 会照样把它落库。**结论不变**:
+            //     h5 那条路径落的仍是 PAYING,下面 `"SUCCESS".equals(newStatus)` 根本不成立 ⇒ 同样不该响。
+            //     错的只是「为什么」那半句。判据的依据始终是**落的状态**,不是「有没有 trade_no」。
+            //   【按调用点逐个量过,不是推断】`applyPayResult` 在 main 里共 **9 个**调用点
+            //     (本类 8 + 回调 1),其中**能写 SUCCESS 的恰好 2 个**:
+            //     `settleFromQuery`(传字面量 "SUCCESS")与回调 `handlePayNotify`(传变量 newStatus)。
+            //     其余 7 个写 PAYING / FAIL,下面这个条件根本不成立 ⇒ 都不该响。
+            //   ★【2026-09-28 订正】此处原写「`isPaySuccess` 为真的调用点**恰好 4 个**:
+            //     createQrOrder:234 / createH5Order:340 落 PAYING,settleFromQuery:448 与回调
+            //     LsyNotifyService 落 SUCCESS(都该响)」。三处不实:
+            //     ① 把两件事混成一件:本条支付链上 `parser.isPaySuccess` 只有 **3 处**(全在本类;
+            //        回调那处**根本不经过该谓词** —— `LsyNotifyService` 自己算 newStatus 后直接调
+            //        `applyPayResult`)。所以按谓词数不是 4,按「谁在写 SUCCESS」数也不是 4(是 2)。
+            //        (main 里另有 6 处 `parser.isPaySuccess`,在取消 / 退款 / 详情查询三条腿上,
+            //          不写订单支付状态,不在本判据的作用域内。)
+            //     ② 原文那三个行号(220/317/414)实测对不上;订正时新写的一组(234/340/448)同样对不上。
+            //        **机制**:写这段注释这个动作本身就会把它引用的那几行往下推,每次订正再推一次
+            //        (实测:一次订正推 6 行,两次共推 12 行)。行号在此处是自指的,写完即失效
+            //        ⇒ 本段改按**方法名**锚定,不再写任何行号。
+            //     **结论不变**:判据的依据始终是**落的状态**(下面那句 "SUCCESS".equals(newStatus)),
+            //     既不是「有没有 trade_no」,也不是「某个谓词为真」。
             //   ⇒ 挂到「真的要写 SUCCESS」这一刻,**真阳性一个不少**,假阳性归零。
             //     这里还额外拿得到 order(payPatch 拿不到),日志因此能带上单号。
             //
@@ -188,9 +208,7 @@ public class LsyOrderService {
         if (order.getAttach() != null) {
             p.put("attach", order.getAttach());
         }
-        if (order.getNotifyUrl() != null) {
-            p.put("notify_url", order.getNotifyUrl());
-        }
+        putUpstreamNotifyUrl(p);
 
         Map<String, Object> resp = client.post("/v3/pay", p);
         return settleAfterPay(order, resp, true);
@@ -224,9 +242,7 @@ public class LsyOrderService {
         if (order.getAttach() != null) {
             p.put("attach", order.getAttach());
         }
-        if (order.getNotifyUrl() != null) {
-            p.put("notify_url", order.getNotifyUrl());
-        }
+        putUpstreamNotifyUrl(p);
         // 注意:聚合码不传 pay_type,上游自动识别支付宝/微信
 
         Map<String, Object> resp = client.post("/v3/qrpay", p);
@@ -311,7 +327,15 @@ public class LsyOrderService {
      *   2. 会返回 PAY_IN_PROCESS
      *   3. 但用户会被跳转到支付 App,停留时长不可控 → 不做同步轮询
      *
-     * 上游「H5支付」目录下无独立关单接口,本类不为 H5 提供关单路径(见 spec §2.3)。
+     * 【与关单的关系 —— 2026-09-28 订正,原句是「上游『H5支付』目录下无独立关单接口,
+     *  本类不为 H5 提供关单路径」】关单确实**不在本类**(这一半仍然成立),它在
+     *  {@code LsyCancelService#closeH5Order};但原句的前半句**字面仍真、推出的结论是假的**:
+     *  142 页文档里标题含「关单」的只有三页,H5 目录下确实一页都没有 ——
+     *  但 {@code /v3/closeorder} 落在「支付接口」目录下,正文只写了可供「二维码支付、JSAPI支付」,
+     *  <b>实测对 H5 一样受理</b>(差分对照:同刻两笔同参 H5,只关 A ⇒ A 由
+     *  {@code PAY_IN_PROCESS} 变 {@code PAY_FAIL},没动的 B 仍是 {@code PAY_IN_PROCESS};
+     *  读数见 {@code LsyCancelService#closeH5Order} 的 javadoc 与 {@code logs/_lsy-close-control.txt})。
+     *  ⇒ 别照这句去推断「H5 关不了单」。
      */
     public LsyPayResultDTO createH5Order(LsyOrderEntity order, String payType) {
         LsyPayType type = LsyPayType.ofRequired(payType);   // AUTO 会被拒绝
@@ -331,9 +355,7 @@ public class LsyOrderService {
         if (order.getAttach() != null) {
             p.put("attach", order.getAttach());
         }
-        if (order.getNotifyUrl() != null) {
-            p.put("notify_url", order.getNotifyUrl());
-        }
+        putUpstreamNotifyUrl(p);
 
         Map<String, Object> resp = client.post("/v3/h5pay", p);
 
@@ -538,6 +560,45 @@ public class LsyOrderService {
         return o == null ? null : String.valueOf(o);
     }
 
+    /**
+     * 把**平台自己的**回调入口填进上送报文 —— 三条下单腿(被扫 / 聚合码 / H5)共用这一处。
+     *
+     * <h2>★ 为什么不是 {@code order.getNotifyUrl()}</h2>
+     * {@code pay_lsy_order.notify_url} 这一列在库里只有一列,却被两种语义共用,方向正好相反:
+     * <ul>
+     *   <li><b>出站</b>(本方法):上送给上游,语义是「**上游**支付完成后回调谁」;</li>
+     *   <li><b>下游</b>:{@code LsyCallbackService.resolveUrl} 把它当作「**我们**推给调用方」的
+     *       目标,且是**第一优先级**({@code open} 那三个 DTO 的 {@code notifyUrl} 就落在这一列)。</li>
+     * </ul>
+     * 改前实现把这一列原样上送 ⇒ 上游会**绕过我们直连调用方**,后果两条:
+     * <ol>
+     *   <li>调用方收到的是上游原文格式,而不是我们对外文档写的下游格式
+     *       ({@code LsyCallbackService.notifyOrder} 的 {@code out_trade_no}/{@code status}/…);</li>
+     *   <li>我们那三个 {@code /payment/collection/notify/**} 端点永远收不到支付回调 ⇒
+     *       {@code LsyNotifyService.handlePayNotify} 事实上是死代码,下单腿只剩轮询兜底
+     *       (而回收银台的设计恰恰<b>只</b>有轮询 —— 见 {@code LeshouyingCashierController} 的类注释)。</li>
+     * </ol>
+     *
+     * <h2>★ 为什么空值就**不发这个键**,而不是退回订单快照里的地址</h2>
+     * 上游文档对这两个接口各写了一句:付款码支付「回调地址,对于轮询超时订单,系统自动对单后会推送
+     * 订单支付结果」、聚合码支付「后端回调地址(<b>不传取机构默认配置回调</b>)」⇒ 不发是**合法**形态,
+     * 上游会走机构默认配置。而「退回订单快照」等于把这个缺陷原样装回去,
+     * 所以这里刻意**没有** fallback。<br>
+     * 另:{@code leshouying.notify-url} 由 {@code LeshouyingProperties.validate()} 在启动时校验非空
+     * (开关关掉时两道闸都不跑),所以正常运行中这个分支不会走到。
+     *
+     * <h2>★ 它不会造出「自己回调自己」的环</h2>
+     * 环的成因是**快照列**里存了我们自己的入口({@code resolveUrl} 第一优先级取它)。
+     * 本方法一个字节都不写库,快照列仍由 {@code LeshouyingOpenController} 从 DTO 落、
+     * 收银台仍留 null ⇒ {@code resolveUrl} 的后两级(api_key.return_url → open_conf.return_url)
+     * 不变,行为与改前一致。
+     */
+    private void putUpstreamNotifyUrl(Map<String, Object> p) {
+        if (StrUtil.isNotBlank(props.getNotifyUrl())) {
+            p.put("notify_url", props.getNotifyUrl());
+        }
+    }
+
     private Long parseAmount(Object v, Long fallback) {
         if (v == null) {
             return fallback;

+ 11 - 1
java/src/main/java/com/payment/platform/module/payment/notification/handler/AccountHandler.java

@@ -157,7 +157,13 @@ public class AccountHandler extends BaseNotifyHandler {
 
     /**
      * 转账/充值结果通知 — alipay.fund.trans.order.changed
-     * 新API字段: order_id(替代order_no), pay_fund_order_id, trans_amount
+     * 新API字段: order_id(替代order_no), pay_fund_order_id, trans_amount, error_code, fail_reason
+     *
+     * <p>★ 失败原因在这个接口叫 {@code fail_reason},**不叫** {@code error_msg} ——
+     * 邻腿 {@link #handleTransChange} 读的是后者,那是另一族接口({@code alipay.commerce.ec.*})。
+     * 两个名字都来自实测报文,不是照文档或互相照抄推的。
+     * 键名与取值见 2026-09-29 生产库 {@code alipay_notify_log.message};
+     * 回归用例 {@code AccountHandlerTest#transOrderChanged_fail_writesErrorCodeAndReason}。
      */
     private void handleTransOrderChanged(Map<String, String> params) {
         String outBizNo = params.get("out_biz_no");
@@ -165,6 +171,8 @@ public class AccountHandler extends BaseNotifyHandler {
         String status = params.get("status");
         String payFundOrderId = params.get("pay_fund_order_id");
         String transAmount = params.get("trans_amount");
+        String errorCode = params.get("error_code");
+        String failReason = params.get("fail_reason");
         log.info("转账通知: out_biz_no={}, order_id={}, status={}", outBizNo, orderId, status);
 
         if (StrUtil.isBlank(outBizNo)) return;
@@ -180,6 +188,8 @@ public class AccountHandler extends BaseNotifyHandler {
         if (StrUtil.isNotBlank(transAmount)) {
             try { transfer.setAmount(new BigDecimal(transAmount)); } catch (Exception ignored) {}
         }
+        if (StrUtil.isNotBlank(errorCode)) transfer.setErrorCode(errorCode);
+        if (StrUtil.isNotBlank(failReason)) transfer.setErrorMsg(failReason);
         transfer.setNextRetryAt(null);
         transfer.setRetryCount(4);
         transferMapper.updateById(transfer);

+ 103 - 0
java/src/main/resources/db/lsy-tenant-page-grants.sql

@@ -0,0 +1,103 @@
+-- ============================================================================
+-- 乐收赢 · 把「银联收单」目录下的三个**只读**页面补授给租户角色(role_id = 2)
+--
+-- 【本脚本尚未执行】执行由用户做。执行前请先读下面「★ 为什么补」与「★ 你可以否掉哪一条」。
+--
+-- 交付日期:2026-09-28
+-- 背景批次:收银台(扫码收款)页面联调。执行完 `lsy-cashier-menu.sql` 之后,
+--           租户角色拿到了「银联收单 → 扫码收款」,但**看不到**同目录下的
+--           支付订单 / 退款记录 / 商户映射 —— 本脚本补上。
+-- ============================================================================
+--
+-- ── ★ 为什么补:三条实测,不是推测 ─────────────────────────────────────────
+--
+-- 1) 活库只读实测(2026-09-28,`GET /system/role/detail/2` 递归遍历):
+--      角色 2(租户角色,17 个用户持有)持 54 个菜单,**268/269/270/271/272 一个都没有**;
+--      同法量角色 1(管理员角色):120 个菜单,五个 id 全有。
+--      ⇒ 两向读数,量具能分辨,不是「查不出来」。
+--
+-- 2) 文档记的**原本**形态是两个角色都有:`lsy-onboarding-menu.sql:105` 写
+--      「「收付款」目录(id=268)与三个 lsy 兄弟页(269/270/271)**也都授给了两个角色**」。
+--      (`lsy-menu.sql` 自己那句「授权给 role_id = 1」是它初版写的,对不上活库现状 ——
+--        同一份脚本第 141 行也承认了这一点。)
+--      ★ 成因**未定,不猜**:可能是有人改过角色配置,也可能是
+--        「角色详情不带授权 ⇒ 抽屉全不勾且保存清空授权」那个缺陷(`1bda59a`,2026-09-28 才修)
+--        期间被谁点过一次保存。两种都不改变本脚本要恢复的目标形态。
+--
+-- 3) 界面文案**要求**租户去那一页:收银台在没有商户号时抛
+--      「该租户未配置商户号,请先在「手工登记商户号」中登记」
+--      (`LsyMerchantService.requireByTenant`)。而「手工登记商户号」正是
+--      `route_name = 'CollectionMerchant'` 那一页改名后的名字。
+--      ⇒ 租户没有这一页时,这句话是**一条死路**:它指的地方租户进不去。
+--
+-- ── ★ 你可以否掉哪一条 ────────────────────────────────────────────────────
+--
+-- 前两页(支付订单 / 退款记录)是**纯读**:控制器只有 `GET /page` 与 `GET /{...}`,
+-- 没有写端点,租户看到的是自己租户的行(实体继承 PaymentTenantBaseEntity,租户拦截器追加条件)。
+--
+-- **第三页(商户映射)是唯一有写权限的**,所以单独说明,你可以只删这一段:
+--   · 该页三个端点(page / save / delete)**共用同一个权限串**
+--     `module_payment:leshouying:merchant:list` ⇒ 授了页面就等于授了新增与删除;
+--   · 新增时归属被强制取自认证上下文(`LeshouyingMerchantController.save` 里
+--     `entity.setTenantId(null)`),请求体里写 tenant_id 无效;
+--   · `uk_lsy_merchant_mch_no` 是 **mch_no 全局唯一**索引 ⇒ 别人已登记的商户号抢不走。
+--   · 残余风险(我认为可接受,但这是判断):租户可以登记一个**尚无人登记**的商户号。
+--     最坏后果是让那个商户号的账户收到本租户顾客的钱(钱进的是对方账户),
+--     以及**占位**(该号之后别人登记不了,需管理员 delete 释放)。
+--   · 若你不接受这一点:把本文件里 `CollectionMerchant` 那一行从 `pages` 的 VALUES 中删掉再执行,
+--     并把上面第 3 条的文案改成「请联系平台管理员开通」。
+--
+-- ── ★ 幂等 ────────────────────────────────────────────────────────────────
+--
+-- 末条 INSERT 逐行 `NOT EXISTS`:**重跑不插重复行,也不报错**。
+-- 与 `lsy-cashier-menu.sql` 不同,本脚本**不依赖任何 CTE 的非空性** ——
+-- 三个页面若已被手工删除,本脚本只授它找得到的行,不会去重建页面(重建请重跑 `lsy-menu.sql`,
+-- 但那份脚本在「目录在、页面被删」时也不会恢复,见它自己的注释)。
+--
+-- ── ★ 只读核对(执行后跑这三条)────────────────────────────────────────────
+--
+-- 1) 目录与三个页面在不在、route_name 是什么(锚点用身份,不用会变的 name):
+--   SELECT id, name, type, route_name, route_path, status
+--     FROM sys_menu
+--    WHERE route_name IN ('Collection','CollectionOrder','CollectionRefund','CollectionMerchant')
+--    ORDER BY id;
+--   期望:**4 行**,status 全 '0'。(本次实测 id:268 目录 / 269 / 270 / 271,
+--         但 id 不是锚点,别把它当判据。)
+--
+-- 2) 授权行:
+--   SELECT rm.role_id, m.name, m.permission
+--     FROM sys_role_menus rm JOIN sys_menu m ON m.id = rm.menu_id
+--    WHERE m.route_name IN ('Collection','CollectionOrder','CollectionRefund','CollectionMerchant')
+--    ORDER BY m.id, rm.role_id;
+--   期望:role 1 与 role 2 **各 4 行 = 共 8 行**。
+--
+-- 3) 端到端(界面):以租户账号重新登录 ⇒ 「银联收单」下应出现 4 个页面。
+--    ★ 必须**重新登录**:路由与权限来自登录时下发的 user 快照,不重登看不到变化。
+--
+-- ── ★ 收回 ────────────────────────────────────────────────────────────────
+--
+--   DELETE FROM sys_role_menus
+--    WHERE role_id = 2
+--      AND menu_id IN (SELECT id FROM sys_menu
+--                       WHERE route_name IN ('Collection','CollectionOrder',
+--                                            'CollectionRefund','CollectionMerchant'));
+--   ★ 收回**目录行**(`route_name='Collection'`)才是让整块从角色 2 视野里消失的那一下 ——
+--     `UserService.buildMenuTree` 对「父不在集合里」的子节点是**静默丢弃**(无 else 分支,不报错)。
+--   ★ 若只想收回商户映射一页,把上面 IN 列表里的 'CollectionMerchant' 去掉即可;
+--     但**不要**连 'Collection' 一起收回,否则另外两页也会一起消失。
+-- ============================================================================
+
+-- 目录 + 三个页面,一次算出要授的 menu_id 集合。锚 route_name(身份),不锚 name(标签)。
+WITH pages AS (
+    SELECT id FROM sys_menu
+     WHERE route_name IN ('CollectionOrder', 'CollectionRefund', 'CollectionMerchant')
+       AND type = 2 AND status = '0'
+    UNION ALL
+    SELECT id FROM sys_menu
+     WHERE route_name = 'Collection' AND type = 1 AND status = '0'
+)
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT 2, p.id
+  FROM pages p
+ WHERE NOT EXISTS (SELECT 1 FROM sys_role_menus rm
+                    WHERE rm.role_id = 2 AND rm.menu_id = p.id);

+ 58 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingResponseParserTest.java

@@ -251,4 +251,62 @@ class LeshouyingResponseParserTest {
             assertNull(ex.getCause(), "空报文没进过 JSON 解析,不该有 cause");
         }
     }
+
+    /**
+     * ★ <b>真实的 {@code PAY_IN_PROCESS} 应答</b> —— 2026-09-28 打测试环境拿到的,逐字抄录。
+     *
+     * <p>来源:给一笔真实的 H5 单({@code /v3/h5pay} 回了 {@code PAY_SUCCESS} + {@code trade_no})
+     * 按 {@code trade_no} 查 {@code /v3/queryorder},上游回了 614 字节 / 24 字段。
+     * 应答自带的 {@code sign} 已用 {@code LeshouyingSigner.verify} 复算通过(null 排除读法)
+     * ⇒ 这份 JSON 是**上游自己发的**,不是我构造的。
+     *
+     * <p>★ <b>为什么必须有一条真货向量</b>:本类此前对 PAY_IN_PROCESS 只有一条**两字段**的合成报文
+     * ({@link #parseV3_payInProcess_isNotSuccessButNotException})。合成报文答不了
+     * 「上游真发的 PAY_IN_PROCESS 长什么样」—— {@code trade_state}、{@code receipt_amount}
+     * 这些字段会不会出现、出现成什么值,只有真货知道。
+     *
+     * <p>★ <b>它同时要钉住文档那句「判断交易结果以 {@code result_code} 为准,{@code trade_state}
+     * 仅供参考」</b>。但注意:真报文里 {@code trade_state=USERPAYING} 与
+     * {@code result_code=PAY_IN_PROCESS} **恰好同向** ⇒ 光看这一条**区分不了**「实现看的是哪个」。
+     * 真正钉住它的是下面那句反向守卫(把两者拆开)。没有那句,本用例在「实现其实读 trade_state」
+     * 下照样全绿。
+     */
+    @Test
+    void parseV3_realPayInProcessResponse_resultCodeBeatsTradeState() {
+        // 逐字抄自 logs/_lsy-query-by-tradeno-resp.bin(614 字节;字段顺序照原文,未重排)
+        String real = "{\"return_code\":\"SUCCESS\",\"return_msg\":\"用户支付中\","
+                + "\"result_code\":\"PAY_IN_PROCESS\",\"pay_type\":\"300\","
+                + "\"mch_name\":\"建行小店基本休息修改\",\"shop_name\":\"\","
+                + "\"mch_no\":\"Y52211090004\",\"shop_no\":\"\","
+                + "\"query_trace_no\":\"483a97c660a14921942751ba3ad4af79\","
+                + "\"pay_trace_no\":\"probe20260928160450h5\",\"pay_time\":\"20260928160450\","
+                + "\"total_amount\":\"1\",\"end_time\":null,"
+                + "\"trade_no\":\"200001070X4326092816045010002\",\"td_trade_no\":null,"
+                + "\"qd_trade_no\":null,\"user_id\":null,\"attach\":null,"
+                + "\"trade_state\":\"USERPAYING\",\"receipt_amount\":\"0\",\"pay_amount\":\"1\","
+                + "\"coupon_amount\":null,\"discount_amount\":\"0\","
+                + "\"sign\":\"18146ea94b94f4264992243d90d199be\"}";
+
+        Map<String, Object> r = parser.parseV3(real);
+        assertEquals("PAY_IN_PROCESS", r.get("result_code"));
+        assertTrue(parser.isPayInProcess(r), "上游真实应答必须是「支付中」");
+        assertFalse(parser.isPaySuccess(r), "支付中不得被判成成功 —— 那会提前写死一个终态");
+        assertFalse(parser.isPayFail(r), "支付中不得被判成失败 —— 那正是本类要防的方向");
+
+        // ★ 真实陷阱:这一单一分钱都还没收到(用户连支付页都没打开),
+        //   而 receipt_amount / discount_amount 都是**非 null 的 "0"**。
+        assertEquals("0", r.get("receipt_amount"));
+        assertEquals("0", r.get("discount_amount"));
+        assertNotNull(r.get("receipt_amount"), "是零,不是 null —— 别写「非 null 即已收款」这种判据");
+
+        // ★ 反向守卫:把 trade_state 改成与 result_code **相反**的说法 ⇒ 判定必须仍跟随 result_code。
+        String contradictoryJson = real.replace(
+                "\"trade_state\":\"USERPAYING\"", "\"trade_state\":\"SUCCESS\"");
+        assertNotEquals(real, contradictoryJson,
+                "正对照:替换必须真的发生,否则下面两句是自证的(搜索串写错就静默失效)");
+        Map<String, Object> contradictory = parser.parseV3(contradictoryJson);
+        assertFalse(parser.isPaySuccess(contradictory),
+                "trade_state 说成功、result_code 说支付中 ⇒ 必须听 result_code(文档明说 trade_state 仅供参考)");
+        assertTrue(parser.isPayInProcess(contradictory));
+    }
 }

+ 113 - 2
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSignerTest.java

@@ -35,8 +35,12 @@ class LeshouyingSignerTest {
         //   auth_code=134573607383541818&inst_no=900075&mch_no=Y52211090004
         //   &pay_time=20190101125959&pay_trace_no=18588dbd1c6e4572b915a2b33fcdc62e
         //   &pay_type=400&total_amount=1
-        // 文档只给了 A,没给最终 sign,所以这里断言的是「A 的拼装结果」——
-        // 用 md5(A + "&key=" + KEY) 反推,验证排序与拼接格式与文档一致。
+        // 【2026-09-28 订正】此处原写「文档只给了 A,没给最终 sign」——**不实**:文档 §3 第五步
+        // 给了一个最终 sign(841387ec9bd3ea78a580bd4629cab010,配 §3 第三步的示例密钥
+        // 8a9bd64e6585c6d8016585c6c8980002,与上面这个 A 正是同一组参数)。但它**复现不出来**:
+        // 按文档自己的四步算是 92bbe9b73e5657f8a5cdcae215c1d697(本次实测)。那条示例值只作示意,
+        // **别拿它当向量**。这里断言的仍是「A 的拼装结果」—— md5(A + "&key=" + KEY),
+        // 验证排序与拼接格式与文档一致。
         Map<String, Object> p = params(
                 "auth_code", "134573607383541818",
                 "total_amount", "1",
@@ -174,4 +178,111 @@ class LeshouyingSignerTest {
         inbound.put("total_amount", "2");
         assertFalse(signer.verify(inbound, KEY), "反向守卫:改一个字节必须被拒");
     }
+
+    /**
+     * ★ <b>入站验签的第三条独立向量 —— 这条来自上游本人,不是文档、也不是我们自算的。</b>
+     *
+     * <p>2026-09-28 用测试环境({@code https://test.gdyfsk.com/yfpay})发了一次**真实的**
+     * {@code POST /v3/queryorder}(查一个刻意不存在的订单),上游回了 481 字节,**应答里自带
+     * {@code sign}**。把那条应答原样喂进 {@code verify()}:它必须认。
+     *
+     * <p>★ <b>为什么非要有这一条</b>:本类其余 {@code verify*} 用例的期望值要么由 {@code sign()}
+     * 产出(自洽),要么由**文档**给出({@link #verify_acceptsSignBuiltFromTheDocumentedVectorIndependently})。
+     * 两条都答不了「<b>上游真实发出来的报文,我们的验签认不认</b>」。而验签是回调入口的**唯一身份证明**,
+     * 认错了就是「伪造回调把订单改成已支付」。
+     *
+     * <p>★ <b>这条向量顺带钉住了一条只能由真货证伪的规则</b>:应答里 26 个字段有 24 个是显式
+     * {@code null}。上游的 sign 是**只对两个非 null 字段**算的
+     * ({@code return_code=FAIL&return_msg=订单信息不存在!})。
+     * 若哪天有人把 {@code buildSource()} 的 {@code filter(v != null)} 去掉(改成「null 当空串参与」),
+     * 本类其余用例**全绿**,而线上**每一条回调都会被判成验签失败** —— 这条会红。
+     *
+     * <p>向量的稳定性:两次独立运行(不同 {@code query_trace_no} / {@code pay_time})拿到**同一个**
+     * {@code sign},因为参与签名的只有那两个非 null 字段 —— 它不随我们的查询参数变化。
+     */
+    @Test
+    void verify_acceptsRealUpstreamResponseFromTestEnv() {
+        // 逐字抄自上游应答(字段顺序照原文;那 24 个 null 是上游真发的,不是我们补的)
+        Map<String, Object> resp = params(
+                "return_code", "FAIL",
+                "return_msg", "订单信息不存在!",
+                "result_code", null,
+                "pay_type", null,
+                "mch_name", null,
+                "shop_name", null,
+                "mch_no", null,
+                "shop_no", null,
+                "query_trace_no", null,
+                "pay_trace_no", null,
+                "pay_time", null,
+                "total_amount", null,
+                "end_time", null,
+                "trade_no", null,
+                "td_trade_no", null,
+                "qd_trade_no", null,
+                "user_id", null,
+                "attach", null,
+                "trade_state", null,
+                "receipt_amount", null,
+                "pay_amount", null,
+                "coupon_amount", null,
+                "discount_amount", null);
+        resp.put("sign", "a3cb8169d863e438486a6ea0ae72fea7");
+
+        assertTrue(signer.verify(resp, KEY), "上游真实应答必须被认 —— 这条挂了说明入站验签与上游不一致");
+
+        // 反向守卫:改了被签字段必须被拒(少了它,上面那句在「verify 恒真」下也会绿)
+        resp.put("return_msg", "订单信息存在!");
+        assertFalse(signer.verify(resp, KEY), "反向守卫:改动被签字段必须被拒");
+    }
+
+    /**
+     * ★ <b>第四条独立向量,也是唯一一条「上游对空字符串签名」的向量</b> —— 2026-09-28 真实主扫下单。
+     *
+     * <p>用测试环境发了一次真实的 {@code POST /v3/qrpay}(1 分钱的聚合码单),上游回 370 字节。
+     * 这条应答里有<b>两个空字符串字段</b>:{@code shop_name} 与 {@code shop_no}
+     * (我们没发 shop_no —— 生产侧「非 null 才发」—— 上游回的是<b>空串而不是 null</b>),
+     * 而这两个空串<b>都在签名原文里</b>。
+     *
+     * <p>★ <b>为什么非要有这一条</b>:文档 §3 第一步把「参数值为空字符串参与,参数值为 null 不参与」
+     * 并列写着,但本类此前对「空串参与」只有<b>自证型</b>用例
+     * ({@link #sign_emptyStringParticipates}:期望值由我们自己按同一条规则算出)。自证型用例
+     * 答不了「上游是不是也这么算」。这条是外部证据 —— 签名是上游自己做的。
+     *
+     * <p>★ 与 {@link #verify_acceptsRealUpstreamResponseFromTestEnv} <b>互补,缺一不可</b>:
+     * 那条钉的是 <b>null 排除</b>(26 个字段里 24 个是 null),这条钉的是 <b>空串保留</b>。
+     * 两条合起来才覆盖文档那句话的两半。只留任一条的话,把 {@code buildSource()} 改成
+     * 「空串也排除」不会有任何用例变红,而入站验签会当场拒掉真实回调。
+     *
+     * <p>顺带钉住一条对账路径的事实:主扫下单成功时 {@code result_code=PAY_SUCCESS},
+     * 而<b>应答里没有 trade_no</b>(qrpay 的返回表本就没有这个字段)。因此主扫单被对账捞起时,
+     * {@code LsyQueryService.queryOrder} 走的一定是 {@code pay_trace_no + pay_time} 那条分支。
+     *
+     * <p>向量稳定性:全部 11 个字段都是那一笔真实订单的数据,不再变化 ⇒ sign 跨运行恒定。
+     */
+    @Test
+    void verify_acceptsRealQrpayResponse_whereUpstreamSignedEmptyStrings() {
+        // 逐字抄自上游应答(字段顺序照原文;两个空串与那串中文都是上游真发的)
+        Map<String, Object> resp = params(
+                "return_code", "SUCCESS",
+                "return_msg", "聚合码请求成功!",
+                "result_code", "PAY_SUCCESS",
+                "mch_name", "建行小店基本休息修改",
+                "shop_name", "",
+                "mch_no", "Y52211090004",
+                "shop_no", "",
+                "pay_trace_no", "probe20260928152205qr",
+                "pay_time", "20260928152205",
+                "total_amount", "1",
+                "code_url", "https://test.gdyfsk.com/yfpay/s/s-00002");
+        resp.put("sign", "00f5d5cb5c37a81ea40a5c35471ad0aa");
+
+        assertTrue(signer.verify(resp, KEY), "上游真实应答(含空串字段)必须被认");
+
+        // 反向守卫:把两个空串之一改成有值 ⇒ 签名原文变了 ⇒ 必须被拒。
+        // 这条守卫**专门**压空串那一格:若 buildSource 哪天把空串当 null 剔掉,
+        // 上面那句会红;若改成「空串与缺值等价」,这句会红。
+        resp.put("shop_no", "86000033007");
+        assertFalse(signer.verify(resp, KEY), "反向守卫:空串改成有值必须被拒");
+    }
 }

+ 308 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingChannelSwitchWiringTest.java

@@ -0,0 +1,308 @@
+package com.payment.platform.module.payment.leshouying.config;
+
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
+import com.payment.platform.module.payment.leshouying.scheduler.LsyOrderPollScheduler;
+import com.payment.platform.module.payment.leshouying.scheduler.LsyReconcileScheduler;
+import com.payment.platform.module.payment.leshouying.service.LsyCallbackService;
+import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
+import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
+import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
+import com.payment.platform.module.payment.leshouying.service.LsyRefundService;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.ApplicationContextInitializer;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.StandardEnvironment;
+import org.springframework.core.env.SystemEnvironmentPropertySource;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 乐收赢通道**总开关**({@code leshouying.enabled})的装配面守卫。
+ *
+ * <p>【它守的是什么】关掉开关必须做到两件事:<b>端点不再存在</b>(不是「存在但返回错误」)、
+ * <b>定时任务不再注册</b>。第二件尤其承重 —— 本模块的自动出站流量全部来自那两个
+ * {@code @Scheduled},而它们用的密钥在生产上曾经是**文档里公开的那个测试密钥**
+ * (见 {@link LeshouyingProperties} 的两道闸)。端点关掉而定时器还在跑,
+ * 等于「看不见但一直在发包」。
+ *
+ * <p>【为什么默认必须是 true】开关的默认值就是这条防线本身。默认 false 的话,
+ * {@code validate()} 那两道闸在生产上**永远不会执行** —— 那正是
+ * {@code SPRING_PROFILES_ACTIVE=prod,dev} 那个漏洞的形态(用一个配置把闸整个绕过去)。
+ * 所以本文件把「默认装配」也钉成一条臂:默认值被改成 false 就是红灯。
+ *
+ * <p>【两处量具,各补对方的盲区】
+ * <ul>
+ *   <li>{@code 两个定时任务…} 两条臂跑**真容器**({@link ApplicationContextRunner}),
+ *       证的是「条件注解在真装配链上生效」;代价是只覆盖被点名的两个类。</li>
+ *   <li>{@code 每个控制器与定时任务都必须带总开关} 走**源码扫描 + 反射**,
+ *       证的是「覆盖面」:新加一个 {@code @RestController} 或 {@code @Scheduled}
+ *       而忘了挂开关时它会红 —— 那是前一类的盲区。它的代价写在方法 javadoc 里。</li>
+ * </ul>
+ *
+ * <p>★ 边界(本文件**不**声称的):它不验「开关打开时端点真的能收单」——
+ * 那由各控制器自己的测试与 {@code LeshouyingPropertiesStartupTest} 负责。
+ */
+class LeshouyingChannelSwitchWiringTest {
+
+    /** 模块根包 —— 源码扫描的根与反射用的类名前缀**同出一源**,免得两处各漂。 */
+    private static final String BASE_PACKAGE = "com.payment.platform.module.payment.leshouying";
+
+    /** 开关属性名:本文件里**只写这一处**,文本扫描与反射断言共用它。 */
+    private static final String SWITCH_KEY = "leshouying.enabled";
+
+    /** 开关缺省时的取值 —— 与 {@code @ConditionalOnProperty} 的 {@code havingValue} 同值。 */
+    private static final String SWITCH_ON = "true";
+
+    /** 源码里去空白后的开关注解开头,文本扫描用(注释已被剥掉,不会自我满足)。 */
+    private static final String SWITCH_ANNOTATION =
+            "@ConditionalOnProperty(name=\"" + SWITCH_KEY + "\"";
+
+    /**
+     * 让**两道启动闸都不参与**的一组配置:非空(过第一道)、且逐项不等于
+     * {@link LeshouyingProperties} 里那三个文档测试常量(不触发第二道),
+     * 于是本文件里唯一的自变量就是开关本身。
+     */
+    private static ApplicationContextRunner base() {
+        return new ApplicationContextRunner()
+                .withUserConfiguration(Cfg.class)
+                .withPropertyValues(
+                        "leshouying.base-url=https://open.gdyfsk.com/yfpay",
+                        "leshouying.inst-no=999999",
+                        "leshouying.key=unit-test-key",
+                        "leshouying.notify-url=https://example.com/api/v1/payment/collection/notify/pay")
+                // 两个调度器的构造依赖:全用 mock,本文件不碰库、不碰上游。
+                .withBean(LsyOrderMapper.class, () -> Mockito.mock(LsyOrderMapper.class))
+                .withBean(LsyRefundMapper.class, () -> Mockito.mock(LsyRefundMapper.class))
+                .withBean(LsyQueryService.class, () -> Mockito.mock(LsyQueryService.class))
+                .withBean(LsyCancelService.class, () -> Mockito.mock(LsyCancelService.class))
+                .withBean(LsyCallbackService.class, () -> Mockito.mock(LsyCallbackService.class))
+                .withBean(LsyOrderService.class, () -> Mockito.mock(LsyOrderService.class))
+                .withBean(LsyRefundService.class, () -> Mockito.mock(LsyRefundService.class))
+                .withUserConfiguration(LsyOrderPollScheduler.class, LsyReconcileScheduler.class);
+    }
+
+    @Configuration(proxyBeanMethods = false)
+    @EnableConfigurationProperties(LeshouyingProperties.class)
+    static class Cfg {
+    }
+
+    /**
+     * ★ <b>默认臂,也是「开关默认值」这条防线的钉子。</b>
+     *
+     * <p>不带任何 {@code leshouying.enabled} 属性 ⇒ 两个定时任务**必须**装配。
+     * 把注解的 {@code matchIfMissing} 改成 false(或字段初值改成 false)都会让本条变红:
+     * 那两种改法在生产上的后果是「通道静默消失」——应用照常启动、日志里什么都看不出来。
+     */
+    @Test
+    void 不给开关属性_两个定时任务照旧装配() {
+        base().run(context -> {
+            assertNull(context.getStartupFailure(), "基础配置必须能起得来,实际: "
+                    + chain(context.getStartupFailure()));
+            assertTrue(context.getBeanNamesForType(LsyOrderPollScheduler.class).length > 0,
+                    "开关缺省时必须装配(默认 true)—— 默认值改成 false 就是通道静默消失");
+            assertTrue(context.getBeanNamesForType(LsyReconcileScheduler.class).length > 0,
+                    "开关缺省时必须装配(默认 true)");
+        });
+    }
+
+    /**
+     * 关掉开关 ⇒ 两个定时任务**一个都不注册**。这是本文件的核心承诺:
+     * 「关掉」意味着不出站,而不只是「界面上点不到」。
+     */
+    @Test
+    void 开关关掉_两个定时任务都不装配() {
+        base().withPropertyValues(SWITCH_KEY + "=false").run(context -> {
+            assertNull(context.getStartupFailure(),
+                    "关掉开关不得让容器起不来,实际: " + chain(context.getStartupFailure()));
+            assertEquals(0, context.getBeanNamesForType(LsyOrderPollScheduler.class).length,
+                    "开关关掉后定时任务仍在注册 ⇒ 它会按 30 秒一次继续向上游发包");
+            assertEquals(0, context.getBeanNamesForType(LsyReconcileScheduler.class).length,
+                    "开关关掉后对账任务仍在注册 ⇒ 它会按 5 分钟一次继续向上游发包");
+        });
+    }
+
+    /**
+     * 覆盖面:模块里**每一个** {@code @RestController} / {@code @Scheduled} 类都必须挂总开关。
+     *
+     * <p>【为什么不能用上面两条臂代替】那两条只覆盖被点名的两个类;
+     * 新写一个控制器照抄邻类(邻类有开关、抄漏了)时它们**全绿**。
+     * 这里改用**源码扫描**而不是登记制清单:文件系统能枚举,所以新增的类自动进入射程,
+     * 不存在「忘了登记」这条缝({@code LeshouyingAdminWiringTest} 用登记制是因为反射枚举不了)。
+     *
+     * <p>【两重量具】扫描负责**谁该有**,反射负责**真的有、且长对了** ——
+     * 只扫文本的话,把注解写到某个方法上(而类上没有)也会被判成合规;
+     * 只反射的话,就退化成登记制。
+     *
+     * <p>★ 本文件里出现 {@code @ConditionalOnProperty(name = "leshouying.enabled"} 这行字
+     * **不会**让本方法自我满足:扫描前先逐行剥注释(同 {@code LeshouyingAdminWiringTest} 的做法),
+     * 而本行在 javadoc 里。
+     *
+     * <p>★ 扫描的已知边界:① 它看的是**源码文本**,所以「注解被拼在常量里」这种写法它认不出
+     * (本仓没有这种写法,且那样写会红 —— 假红方向安全);② 剥注释是按行的,
+     * 不看字符串字面量 —— 对这些文件安全:注解行里不含 {@code //}。
+     */
+    @Test
+    void 每个控制器与定时任务都必须带总开关() throws Exception {
+        Path root = Path.of("src/main/java", BASE_PACKAGE.replace('.', '/'));
+        assertTrue(Files.isDirectory(root), "找不到源码根 " + root.toAbsolutePath()
+                + "(cwd=" + Path.of("").toAbsolutePath() + ")—— 本用例须从模块根目录运行");
+
+        List<String> onSwitch = new ArrayList<>();
+        List<String> offSwitch = new ArrayList<>();
+        List<Path> sources;
+        try (Stream<Path> walk = Files.walk(root)) {
+            sources = walk.filter(p -> p.toString().endsWith(".java")).toList();
+        }
+
+        for (Path file : sources) {
+            String code = stripComments(Files.readString(file, StandardCharsets.UTF_8));
+            if (!code.contains("@RestController") && !code.contains("@Scheduled(")) {
+                continue;
+            }
+            String name = file.getFileName().toString();
+            boolean textOk = code.replaceAll("\\s+", "").contains(SWITCH_ANNOTATION);
+            if (textOk && hasSwitchOnType(toFqcn(root, file))) {
+                onSwitch.add(name);
+            } else {
+                offSwitch.add(name);
+            }
+        }
+
+        // ── 正对照:先证明「扫描确实在看这些文件」,否则下面那条空集断言是平凡真 ──────────
+        // ★ 对照必须落在**扫描结果**上(两袋之和),不能落在 onSwitch 上:
+        //   后者只在功能**已实现**时才可能非空 ⇒ 那样这条对照与「功能存在」是同一件事,
+        //   功能没写时它会先炸,把「量具失效」和「还没实现」报成同一条消息。
+        List<String> scanned = new ArrayList<>(onSwitch);
+        scanned.addAll(offSwitch);
+        assertTrue(sources.size() > 1, "一个源文件都没扫到 —— 扫描根写错了,此时 offSwitch 为空毫无意义");
+        assertTrue(scanned.contains("LeshouyingCashierController.java"),
+                "扫描没扫到已知成员(现金台控制器)—— 量具失效,不是代码合规。"
+                        + "扫到的文件数=" + sources.size() + ",命中名单=" + scanned);
+        assertTrue(scanned.contains("LsyOrderPollScheduler.java"),
+                "扫描没扫到已知成员(被扫轮询任务)—— 量具失效,不是代码合规。命中名单=" + scanned);
+
+        assertTrue(offSwitch.isEmpty(),
+                "这些类没有总开关(或注解没挂在类上、参数不对)⇒ leshouying.enabled=false 时它们照旧装配,"
+                        + "而 prod 上那份配置用的是文档公开的测试密钥: " + offSwitch);
+    }
+
+    /**
+     * ★ <b>用户实际要敲的那一行到底生不生效。</b>
+     *
+     * <p>上面那条臂注入的是**规范化**属性名 `leshouying.enabled`;而 compose 里那一行是
+     * {@code LESHOUYING_ENABLED: "${LESHOUYING_ENABLED:-true}"} —— **环境变量形态**。
+     * 两者之间隔着 Spring 的宽松绑定:绑不上的话,线上那句开关就是空操作,
+     * 而症状(应用照旧起不来)与「开关没写」一模一样,极难分辨。
+     * 所以「照文档敲这一行就行」这句话必须**实测**,不能靠 Spring 的常识推。
+     */
+    @Test
+    void 环境变量形态_真的被规范化成_leshouying_point_enabled() {
+        // ★ 量具的选择:本类**不能**直接 new SpringConfigurationPropertySource
+        //   (那个类不是 public)。改用 Binder.get(Environment) —— 它内部走的就是
+        //   ConfigurationPropertySources.get(environment),即生产上同一支适配器:
+        //   SystemEnvironmentPropertySource 会被配上 SystemEnvironmentPropertyMapper。
+        //   下面这条断言因此证的是**那条适配链**,不是我自己手搓的映射。
+        StandardEnvironment env = new StandardEnvironment();
+        env.getPropertySources().addFirst(
+                new SystemEnvironmentPropertySource("compose-env", Map.of("LESHOUYING_ENABLED", "false")));
+        assertEquals(Boolean.FALSE,
+                Binder.get(env).bind("leshouying.enabled", Boolean.class).orElse(null),
+                "环境变量 LESHOUYING_ENABLED 没有被宽松绑定成 leshouying.enabled —— "
+                        + "那 compose 里那一行就是空操作(@ConditionalOnProperty 读的是规范化名)");
+
+        // 正对照:同一支量具对**未被赋值**的键必须给不出值,否则上面那条可能只是「恒为 false」。
+        assertNull(Binder.get(env).bind("leshouying.never-set", Boolean.class).orElse(null),
+                "量具对不存在的键也给出了值 —— 上面那条断言不可信");
+    }
+
+    /**
+     * 同一条注入路径在**真容器**里过一遍:环境变量形态必须真的把定时任务摘掉。
+     *
+     * <p>★ 这里**不能**用 {@code withPropertyValues("LESHOUYING_ENABLED=false")}。
+     * 实测(2026-09-28):那样写开关**不生效** ——
+     * {@code Tests run: 5, Failures: 1 … expected: <0> but was: <1>}。
+     * 成因在栈里读得出来,不用推:{@code AbstractApplicationContextRunner.run} 把
+     * {@code withPropertyValues} 交给 {@code TestPropertyValues.applyToSystemProperties}
+     * (栈帧 {@code TestPropertyValues.java:188}),落成的是 **JVM 系统属性**;
+     * 而「去掉分隔符、全大写」这条宽松形态是 {@code SystemEnvironmentPropertySource}
+     * **专有**的,系统属性只认规范名。
+     *
+     * <p>⇒ 落到运维上是一条实打实的坑:{@code JAVA_OPTS=-DLESHOUYING_ENABLED=false}
+     * 关不掉开关,而症状(应用照旧被启动闸挡住)与「压根没写这一行」一模一样。
+     * compose 里那一行走的是容器环境变量,是通的;要关就用那条。
+     */
+    @Test
+    void 环境变量形态_真容器里也能关掉装配() {
+        // 必须是**环境变量源**:系统属性源在这里量的是另一件事(见上面的实测)。
+        ApplicationContextInitializer<ConfigurableApplicationContext> injectComposeEnv = ctx ->
+                ctx.getEnvironment().getPropertySources().addFirst(
+                        new SystemEnvironmentPropertySource("compose-env",
+                                Map.of("LESHOUYING_ENABLED", "false")));
+        base().withInitializer(injectComposeEnv).run(context -> {
+            assertNull(context.getStartupFailure(), "实际: " + chain(context.getStartupFailure()));
+            assertEquals(0, context.getBeanNamesForType(LsyOrderPollScheduler.class).length,
+                    "以环境变量形态关开关时定时任务仍被注册 —— compose 那条注入路径不生效");
+            // 两个调度器都要摘 —— 只摘一个说明注解在某些类上没生效(覆盖面那条臂的另一半)。
+            assertEquals(0, context.getBeanNamesForType(LsyReconcileScheduler.class).length,
+                    "对账任务也必须一并摘掉");
+        });
+    }
+
+    /** 开关注解**在类上**且三个参数都对 —— {@code havingValue}/{@code matchIfMissing} 一个不对就是关不干净。 */
+    private static boolean hasSwitchOnType(String fqcn) throws ClassNotFoundException {
+        ConditionalOnProperty c = Class.forName(fqcn).getAnnotation(ConditionalOnProperty.class);
+        return c != null
+                && Arrays.asList(c.name()).contains(SWITCH_KEY)
+                && Arrays.asList(c.havingValue()).contains(SWITCH_ON)
+                && c.matchIfMissing();
+    }
+
+    private static String toFqcn(Path root, Path file) {
+        String rel = root.relativize(file).toString().replace('\\', '/');
+        return BASE_PACKAGE + "." + rel.substring(0, rel.length() - ".java".length()).replace('/', '.');
+    }
+
+    /** 逐行剥注释:整行 `//`、块注释行(`*` / `/*` 开头)、行尾 `//` 之后的部分。 */
+    private static String stripComments(String text) {
+        StringBuilder out = new StringBuilder();
+        for (String line : text.split("\n", -1)) {
+            String t = line.trim();
+            if (t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")) {
+                continue;
+            }
+            int cut = line.indexOf("//");
+            out.append(cut >= 0 ? line.substring(0, cut) : line).append('\n');
+        }
+        return out.toString();
+    }
+
+    /** 把异常链上的消息拼起来 —— 根因常埋在 `BeanCreationException` 的 cause 里。 */
+    private static String chain(Throwable t) {
+        StringBuilder sb = new StringBuilder();
+        for (Throwable c = t; c != null; c = c.getCause()) {
+            sb.append(c.getClass().getSimpleName()).append(": ").append(c.getMessage()).append(" | ");
+            if (sb.length() > 4000) {
+                break;
+            }
+        }
+        return sb.toString();
+    }
+}

+ 56 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingPropertiesStartupTest.java

@@ -6,6 +6,7 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.annotation.Configuration;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -33,9 +34,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
  * 「本 bean 真的在 `@ComponentScan` 范围内」这件事 —— 那一条仍由
  * `PaymentApplicationContextTest` 负责,两者不互相替代。
  *
- * <p>★ <b>三条臂是一次单变量对照</b>:臂一与臂二**只差** `spring.profiles.active` 一格,
+ * <p>★ <b>前三条臂是一次单变量对照</b>:臂一与臂二**只差** `spring.profiles.active` 一格,
  * 其余属性逐字相同 ⇒ 臂一的失败只能是 profile 闸造成的,而不是「属性没绑上」。
  * 臂二的断言就是为这个「只差一格」作证的。
+ *
+ * <p>★ 后两条臂(2026-09-28 追加)问的是**反方向**的问题:不是「什么情况下必须起不来」,
+ * 而是「明确说了不起用时,怎样才起得来」。它们与「装配面」成对 ——
+ * 放行的那一半在这里,摘掉端点与定时任务的那一半在
+ * {@code LeshouyingChannelSwitchWiringTest}。
  */
 class LeshouyingPropertiesStartupTest {
 
@@ -139,4 +145,53 @@ class LeshouyingPropertiesStartupTest {
                             "第一道闸必须先炸,不该报成「文档测试配置」把人指向错误方向。实际链: " + all);
                 });
     }
+
+    /**
+     * ★ <b>总开关关掉时,两道闸一并放行</b> —— 这是「本批通道不上线」的出口。
+     *
+     * <p>【为什么需要它】臂一证明了「拿文档测试配置上 prod 就起不来」,而这条防线在
+     * 2026-09-28 那天的实际后果是:通道**没有任何真实凭据**(进件未完成),于是它凭一己之力
+     * 把整个应用挡在生产门外 —— 而它本可以只是不干活。本臂给出的就是那条
+     * 「不起用、但也别挡路」的路。
+     *
+     * <p>【为什么这不算打穿 fail-closed】`enabled` **默认 true**(字段初值与
+     * `@ConditionalOnProperty` 的 `matchIfMissing` 两处都是),所以「忘设」仍旧走闸 ——
+     * 臂一就是这条的正对照(它不带 enabled,仍然必须炸)。只有**显式**写 false 才放行,
+     * 且 {@link LeshouyingProperties#validate()} 会打一条 WARN,不是静默。
+     *
+     * <p>【这一臂与装配面正交】它只证「起得来」;「起得来的同时真的不装配任何端点与定时任务」
+     * 由 {@code LeshouyingChannelSwitchWiringTest} 负责。两条缺一不可:
+     * 只放行而不摘装配 = 拿公开的测试密钥继续收单。
+     */
+    @Test
+    void prodProfile_withDocTestConfig_butChannelExplicitlyDisabled_startsContext() {
+        docTestConfig()
+                .withPropertyValues("spring.profiles.active=prod", "leshouying.enabled=false")
+                .run(context -> {
+                    assertNull(context.getStartupFailure(),
+                            "enabled=false 是「本批通道不上线」的出口,必须起得来,实际: "
+                                    + chain(context.getStartupFailure()));
+                    assertFalse(context.getBean(LeshouyingProperties.class).isEnabled(),
+                            "正对照:leshouying.enabled 没绑上 —— 那样本臂证明的就不是开关放行");
+                });
+    }
+
+    /**
+     * 开关关掉时,**第一道闸(配置缺失)也一并放行**。
+     *
+     * <p>这是生产上更容易撞到的那种:compose 里写成
+     * {@code LESHOUYING_KEY: "${LESHOUYING_KEY:-}"} 这类空值注入,或者压根没注入。
+     * 只放行第二道闸的话,启动仍会被「配置缺失」挡住 —— 出口只开了一半,
+     * 而症状(起不来)与没加开关时**一模一样**,排查时极易误判成「开关没生效」。
+     */
+    @Test
+    void prodProfile_withBlankKey_butChannelExplicitlyDisabled_startsContext() {
+        docTestConfig()
+                .withPropertyValues(
+                        "spring.profiles.active=prod",
+                        "leshouying.key=",
+                        "leshouying.enabled=false")
+                .run(context -> assertNull(context.getStartupFailure(),
+                        "开关关掉时两道闸都应放行,实际: " + chain(context.getStartupFailure())));
+    }
 }

+ 211 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderControllerTest.java

@@ -0,0 +1,211 @@
+package com.payment.platform.module.payment.leshouying.controller;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
+import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
+import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper;
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * 订单抽屉要用的两条只读查询:回调轨迹、本单的退款单。
+ *
+ * <p>【为什么这个类的断言落在 wrapper 上,而不是落在返回值上】
+ * `selectList` 是 mock,返回什么由用例自己 stub —— 断言返回值等于自己刚设的值,恒真。
+ * 唯一能证明「查的是哪些 key」的读数是**去看 wrapper 里绑的值**(同款手法见
+ * {@code LsyNotifyServiceTest#capturedRefundLookup})。
+ *
+ * <p>【缺口是什么】`notifyLogs` 原先只按订单自己的 `pay_trace_no` 查,
+ * 而**退款回调的审计行绑的是那笔退款的 trace**(上游报文里的 `pay_trace_no` 就是我们的
+ * `refund_trace_no`,见 `LsyNotifyService#handleRefundNotify`)⇒ 退款回调行**结构上**
+ * 永远不可能出现在订单的回调轨迹里。用户 2026-09-29 实测到的正是这个:
+ * 订单已「全额退款」,回调轨迹却仍然是支付的那几条。
+ */
+@ExtendWith(MockitoExtension.class)
+class LeshouyingOrderControllerTest {
+
+    @Mock private LsyOrderMapper orderMapper;
+    @Mock private LsyNotifyLogMapper notifyLogMapper;
+    @Mock private LsyRefundMapper refundMapper;
+
+    private LeshouyingOrderController controller;
+
+    @BeforeEach
+    void setUp() {
+        controller = new LeshouyingOrderController(orderMapper, notifyLogMapper, refundMapper);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在
+        // 无 Spring 上下文的单测中工作(否则抛 can not find lambda cache for this entity)。
+        // 本类**每一条**断言都读 wrapper,所以这一句是必需的,不是保险起见。
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyOrderEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyRefundEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyNotifyLogEntity.class);
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private LsyOrderEntity order() {
+        LsyOrderEntity o = new LsyOrderEntity();
+        o.setId(1L);
+        o.setTenantId(1L);
+        o.setOutTradeNo("OUT001");
+        o.setPayTraceNo("PT001");
+        o.setStatus("REFUNDED");
+        o.setTotalAmount(100L);
+        o.setRefundedAmount(100L);
+        return o;
+    }
+
+    private LsyRefundEntity refund(long id, String trace) {
+        LsyRefundEntity r = new LsyRefundEntity();
+        r.setId(id);
+        r.setTenantId(1L);
+        r.setOrderId(1L);
+        r.setOutTradeNo("OUT001");
+        r.setOutRefundNo("RF00" + id);
+        r.setRefundTraceNo(trace);
+        r.setRefundFee(30L);
+        r.setStatus("SUCCESS");
+        return r;
+    }
+
+    /** 取出审计表的 `selectList` 实际用掉的 wrapper。 */
+    @SuppressWarnings("unchecked")
+    private AbstractWrapper<?, ?, ?> capturedLogQuery() {
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LsyNotifyLogEntity>> c =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(notifyLogMapper).selectList(c.capture());
+        return (AbstractWrapper<?, ?, ?>) c.getValue();
+    }
+
+    @SuppressWarnings("unchecked")
+    private AbstractWrapper<?, ?, ?> capturedRefundQuery() {
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LsyRefundEntity>> c =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(refundMapper).selectList(c.capture());
+        return (AbstractWrapper<?, ?, ?>) c.getValue();
+    }
+
+    /**
+     * WHERE 里 `pay_trace_no IN (...)` 绑的**每一个**值。
+     *
+     * <p>★ 两个坑,都是实测踩出来的,不是保险起见:
+     * <ol>
+     *   <li><b>必须先把 `getSqlSegment()` 求出来</b> —— `getParamNameValuePairs()` 是在渲染 SQL 段
+     *       那一步才被填的。先读 map 会读到**空 map**,于是「一个值都没有」与「断言写错了」
+     *       两种情形长得一模一样(第一版就是这么红的)。</li>
+     *   <li><b>`in(...)` 的元素是逐个占位符,不是一个 Collection</b> ——
+     *       渲染出来是 `IN (#{...MPGENVAL1},#{...MPGENVAL2},#{...MPGENVAL3})`。
+     *       按「找 map 里那个 Collection」去取会一个都找不到。</li>
+     * </ol>
+     */
+    private static List<Object> inValues(AbstractWrapper<?, ?, ?> w) {
+        String sql = w.getSqlSegment();                 // ← 必须先求值,见上
+        int i = sql.indexOf("pay_trace_no IN (");
+        assertTrue(i >= 0, "WHERE 里应有 pay_trace_no IN (...);实测: " + sql);
+        String tail = sql.substring(i, sql.indexOf(')', i));
+        Matcher m = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)").matcher(tail);
+        List<Object> out = new ArrayList<>();
+        while (m.find()) {
+            out.add(w.getParamNameValuePairs().get(m.group(1)));
+        }
+        assertFalse(out.isEmpty(), "IN 列表不该是空的;实测: " + sql);
+        return out;
+    }
+
+    /** WHERE 里绑定的**所有**值(同样必须在 `getSqlSegment()` 之后读)。 */
+    private static List<Object> boundValues(AbstractWrapper<?, ?, ?> w) {
+        w.getSqlSegment();
+        return new ArrayList<>(w.getParamNameValuePairs().values());
+    }
+
+    // ───────────────────────── 回调轨迹 ─────────────────────────
+
+    @Test
+    void notifyLogs_coversTheOrderTraceAndEveryRefundTrace() {
+        when(orderMapper.selectOne(any())).thenReturn(order());
+        when(refundMapper.selectList(any())).thenReturn(List.of(refund(9L, "RT001"), refund(10L, "RT002")));
+        when(notifyLogMapper.selectList(any())).thenReturn(List.of());
+
+        controller.notifyLogs("OUT001");
+
+        AbstractWrapper<?, ?, ?> w = capturedLogQuery();
+        assertTrue(w.getSqlSegment().contains("pay_trace_no IN"),
+                "必须按 pay_trace_no 的**集合**查;实测: " + w.getSqlSegment());
+        assertEquals(Set.of("PT001", "RT001", "RT002"), new HashSet<>(inValues(w)),
+                "订单自身的 trace + 每一笔退款的 trace 缺一不可 —— 少任何一个,"
+                        + "对应的回调行就结构性地看不见(这正是用户看到的现象)");
+    }
+
+    @Test
+    void notifyLogs_withoutAnyRefund_stillQueriesTheOrderTraceAlone() {
+        when(orderMapper.selectOne(any())).thenReturn(order());
+        when(refundMapper.selectList(any())).thenReturn(List.of());
+        when(notifyLogMapper.selectList(any())).thenReturn(List.of());
+
+        controller.notifyLogs("OUT001");
+
+        assertEquals(Set.of("PT001"), new HashSet<>(inValues(capturedLogQuery())),
+                "没有退款时行为必须与改动前一致(IN 一个元素的语义等于等值)");
+    }
+
+    @Test
+    void notifyLogs_unknownOrder_is404_andQueriesNothingElse() {
+        when(orderMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.notifyLogs("NOPE"));
+
+        // 与 requireOrder 的既有纪律一致:不区分「不存在」与「不属于你」
+        assertEquals(404, e.getCode());
+        verify(refundMapper, never()).selectList(any());
+        verify(notifyLogMapper, never()).selectList(any());
+    }
+
+    // ───────────────────────── 本单的退款单 ─────────────────────────
+
+    @Test
+    void orderRefunds_areScopedToThisOrder() {
+        when(orderMapper.selectOne(any())).thenReturn(order());
+        when(refundMapper.selectList(any())).thenReturn(List.of(refund(9L, "RT001")));
+
+        controller.refunds("OUT001");
+
+        AbstractWrapper<?, ?, ?> w = capturedRefundQuery();
+        assertTrue(w.getSqlSegment().contains("out_trade_no ="),
+                "退款单必须按本单过滤;实测: " + w.getSqlSegment());
+        assertTrue(boundValues(w).contains("OUT001"),
+                "过滤条件绑的必须是**本单**的订单号;实测绑定值=" + boundValues(w));
+    }
+
+    @Test
+    void orderRefunds_unknownOrder_is404() {
+        when(orderMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.refunds("NOPE"));
+
+        assertEquals(404, e.getCode());
+        verify(refundMapper, never()).selectList(any());
+    }
+}

+ 155 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.java

@@ -1,9 +1,14 @@
 package com.payment.platform.module.payment.leshouying.scheduler;
 
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
@@ -18,6 +23,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.LoggerFactory;
 
 import java.time.OffsetDateTime;
 import java.util.LinkedHashMap;
@@ -25,6 +31,7 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Consumer;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -32,6 +39,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
@@ -613,6 +621,153 @@ class LsySchedulerTest {
      * <p>返回值刻意区分「谓词不在」({@code null}) 与「值不对」(非 null 的别的串):
      * 断言失败消息里会一并打出 {@code getSqlSegment()},两种成因当场可分。
      */
+    // ============ 上游「这笔单/这笔退款我这儿没有」是**正常业务回答**,不是异常 ============
+
+    /**
+     * 未扫的聚合码单在 `/v3/queryorder` 上回的**就是** `return_code=FAIL` +「订单信息不存在!」,
+     * 而这是该单种类的**正常态**:差分对照实测,同一刻建的两笔同参聚合码单,
+     * 在关单动作**前后**都查不到(读数 `logs/_lsy-close-control.txt`)。⇒ 与关单无关,
+     * 是「未扫的聚合码单」这一类单在查询接口上的固有表现。
+     *
+     * <p>`LeshouyingResponseParser.parseV3` 对 `return_code != SUCCESS` 一律**抛异常**
+     * (那是出站腿要的语义:通信层失败必须炸),于是这一跳穿过 `LsyQueryService.queryOrder`
+     * 落进本调度器的 `catch (Exception)`,被记成**带完整栈的 ERROR** —— 每 5 分钟一条,
+     * 直到该单被绝对过期收走。栈本身零信息量(每次都是同一条
+     * `postForRawJson → parseV3 → throw`),而一条恒假的 ERROR 会训练运维忽略 ERROR。
+     *
+     * <p>【失败方向】判据是「上游原文**包含**某句话」,不是「凡是 LeshouyingApiException 就降级」:
+     * 上游改了措辞 ⇒ 我们退回 **noisy**(ERROR),**不会**退回 silent。这条不对称是故意的。
+     */
+    @Test
+    void reconcilePendingOrders_上游说订单不存在_记WARN不带栈() {
+        LsyOrderEntity o = paying(0);
+        o.setPayScene("QRCODE");
+        when(orderMapper.selectList(any())).thenReturn(List.of(o));
+        when(queryService.queryOrder(o)).thenThrow(
+                new LeshouyingApiException("FAIL", "订单信息不存在!", "{}"));
+
+        withLogCapture(LsyReconcileScheduler.class, events -> {
+            assertDoesNotThrow(() -> scheduler().reconcilePendingOrders());
+
+            assertTrue(errors(events).isEmpty(),
+                    "未扫聚合码单是正常态,不得记 ERROR,实测: " + errors(events));
+            List<ILoggingEvent> warns = warns(events);
+            assertEquals(1, warns.size(), "应有一条 WARN,实测: " + messages(events));
+            assertNull(warns.get(0).getThrowableProxy(), "正常业务回答不挂栈");
+            assertTrue(warns.get(0).getFormattedMessage().contains("OUT001"),
+                    "WARN 必须带得走是哪一笔,实测: " + warns.get(0).getFormattedMessage());
+            assertTrue(warns.get(0).getFormattedMessage().contains("订单信息不存在"),
+                    "上游原文必须留下 —— 否则这一条只剩「失败了」,排障时无从判断,实测: "
+                            + warns.get(0).getFormattedMessage());
+        });
+        // 只改日志级别,**不改行为**:订单状态一个字都不许动
+        verify(orderService, never()).settleFromQuery(any(), any());
+    }
+
+    /**
+     * 反向守卫:**别的**上游失败必须仍是带栈 ERROR。
+     *
+     * <p>没有这一条,一个「把所有 LeshouyingApiException 都降成 WARN」的实现
+     * (更省事,也更像「顺手放宽」)会让上面那条全绿 —— 而验签失败、商户未配置、
+     * 参数被拒这类**每笔都会失败**的真故障会一起被静音。
+     *
+     * <p>本条在加实现**之前**就是绿的(它钉的是既有行为),所以它的价值只能由变异证明:
+     * 把实现改成无条件降级 ⇒ 本条必须红。读数见提交信息。
+     */
+    @Test
+    void reconcilePendingOrders_其他上游失败_仍是带栈ERROR() {
+        LsyOrderEntity o = paying(0);
+        o.setPayScene("QRCODE");
+        when(orderMapper.selectList(any())).thenReturn(List.of(o));
+        when(queryService.queryOrder(o)).thenThrow(
+                new LeshouyingApiException("FAIL", "验签失败", "{}"));
+
+        withLogCapture(LsyReconcileScheduler.class, events -> {
+            assertDoesNotThrow(() -> scheduler().reconcilePendingOrders());
+
+            assertEquals(1, errors(events).size(), "真故障必须留在 ERROR,实测: " + messages(events));
+            assertNotNull(events.stream().filter(e -> e.getLevel() == Level.ERROR)
+                            .findFirst().orElseThrow().getThrowableProxy(),
+                    "ERROR 必须带栈 —— 栈是查这条日志的人唯一能顺下去的东西");
+        });
+    }
+
+    /**
+     * 退款腿同一形状 —— 依据是**实测量出来的**,不是「看着像所以顺手也改」:
+     * 拿一笔上游不认识的 `refund_trace_no` 打 `/v3/queryrefund`,实测回
+     * `return_code=FAIL` / `result_code=null` / `return_msg=订单信息不存在!`
+     * (原始字节 `logs/_lsy-lc4/qrefund-bogus-trace.bin`,422 B,sign=null)。
+     *
+     * <p>而这正是本腿存在的理由:`LsyRefundService.createRefund` 把退款行**预插入**成 `CREATED`,
+     * 那一刻的语义是「我们记下了,但**还不知道上游有没有收到**」(见该方法 :175-181 的注释)。
+     * 预插入之后 POST 超时/连接重置的那些行,上游根本没见过这笔退款 —— 每轮对账问到的
+     * 就是这句话。把它记成带栈 ERROR,等于把**设计里明写的正常态**报成故障。
+     *
+     * <p>(同一个探针也测了 `/v3/qrclose`:回 `FAIL` +「该聚合码链接不存在或已过期!」。
+     * 但 `closeExpiredQrOrders` **没有**改成 WARN —— 扫描条件是「过期且仍 PAYING 且有 code_url」,
+     * 那种行是 qrpay 成功之后才有的,**我没有它复发的读数**,不凭形状推断。)
+     */
+    @Test
+    void reconcilePendingRefunds_上游说订单不存在_记WARN不带栈() {
+        LsyRefundEntity r = pendingRefund();
+        when(refundMapper.selectList(any())).thenReturn(List.of(r));
+        when(refundService.queryRefund(r)).thenThrow(
+                new LeshouyingApiException("FAIL", "订单信息不存在!", "{}"));
+
+        withLogCapture(LsyReconcileScheduler.class, events -> {
+            assertDoesNotThrow(() -> scheduler().reconcilePendingRefunds());
+
+            assertTrue(errors(events).isEmpty(),
+                    "实测这是正常业务回答,不得记 ERROR,实测: " + errors(events));
+            List<ILoggingEvent> warns = warns(events);
+            assertEquals(1, warns.size(), "实测: " + messages(events));
+            assertNull(warns.get(0).getThrowableProxy());
+            assertTrue(warns.get(0).getFormattedMessage().contains("RF001"),
+                    "必须带得走是哪一笔,实测: " + warns.get(0).getFormattedMessage());
+        });
+        // ★ 饿死防线不得被本改动碰到:抛异常的行走的仍是同一条推游标的路。
+        verify(refundMapper).update(any(), any());
+    }
+
+    @Test
+    void reconcilePendingRefunds_其他上游失败_仍是带栈ERROR() {
+        LsyRefundEntity r = pendingRefund();
+        when(refundMapper.selectList(any())).thenReturn(List.of(r));
+        when(refundService.queryRefund(r)).thenThrow(
+                new LeshouyingApiException("FAIL", "验签失败", "{}"));
+
+        withLogCapture(LsyReconcileScheduler.class, events -> {
+            assertDoesNotThrow(() -> scheduler().reconcilePendingRefunds());
+            assertEquals(1, errors(events).size(), "实测: " + messages(events));
+        });
+    }
+
+    /** 与 LsyQrPayTest / LsyNotifyServiceTest 同款(本仓库按测试文件各自持有一份,不抽公共类)。 */
+    private static void withLogCapture(Class<?> type, Consumer<List<ILoggingEvent>> body) {
+        Logger logger = (Logger) LoggerFactory.getLogger(type);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        try {
+            body.accept(appender.list);
+        } finally {
+            logger.detachAppender(appender);
+        }
+    }
+
+    private static List<ILoggingEvent> warns(List<ILoggingEvent> events) {
+        return events.stream().filter(e -> e.getLevel() == Level.WARN).toList();
+    }
+
+    private static List<String> errors(List<ILoggingEvent> events) {
+        return events.stream().filter(e -> e.getLevel() == Level.ERROR)
+                .map(ILoggingEvent::getFormattedMessage).toList();
+    }
+
+    private static List<String> messages(List<ILoggingEvent> events) {
+        return events.stream().map(e -> e.getLevel() + " " + e.getFormattedMessage()).toList();
+    }
+
     private static String equalityStatusGuardValue(AbstractWrapper<?, ?, ?> w) {
         Matcher m = Pattern.compile("status = #\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
                 .matcher(w.getSqlSegment());

+ 60 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyBarcodePayTest.java

@@ -143,6 +143,66 @@ class LsyBarcodePayTest {
         assertEquals("20260921120000", sent.get("pay_time"));
     }
 
+    /**
+     * 上送上游的 {@code notify_url} 必须是**平台自己的回调入口**({@code leshouying.notify-url}),
+     * <b>不是</b>订单快照里那一列。
+     *
+     * <p>【为什么这条是硬要求】{@code pay_lsy_order.notify_url} 在库里只有一列,却被两种语义共用:
+     * 它同时是 {@code LsyCallbackService.resolveUrl} 的**第一优先级**「我们推给下游」的目标
+     * (开放 API 调用方在 DTO 里传进来的那个地址)。修前实现把这个地址原样上送,等于告诉上游
+     * 「支付结果请直接回调调用方」—— 后果有两层:
+     * <ol>
+     *   <li>调用方收到的是**上游原文格式**,而我们对外文档写的是我们自己的下游回调格式
+     *       ({@code out_trade_no}/{@code status}/…,见 {@code LsyCallbackService.notifyOrder})
+     *       ⇒ 按文档对接的调用方一条都对不上;</li>
+     *   <li>我们自己那三个 {@code /payment/collection/notify/**} 端点(免登录 + 验签)
+     *       <b>永远收不到支付回调</b> ⇒ {@code LsyNotifyService.handlePayNotify} 成了死代码,
+     *       下单腿只剩轮询兜底。</li>
+     * </ol>
+     * 两条都不是「行为偏好」,是与对外契约相矛盾。
+     *
+     * <p>反向守卫见下一条:平台地址没配全时**不发这个键**(上游文档写明「不传取机构默认配置回调」),
+     * 而<b>不是</b>退回调用方的地址 —— 退回就等于把这个缺陷又装回去。
+     */
+    @Test
+    void barcode_sendsPlatformNotifyUrl_upstream_notTheCallers() {
+        props.setNotifyUrl("https://platform.example/api/v1/payment/collection/notify/pay");
+        when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        LsyOrderEntity o = order();
+        o.setNotifyUrl("https://caller.example/their-own-callback");
+        orderService.createBarcodeOrder(o, "134567890123456789", "000");
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/pay"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertEquals("https://platform.example/api/v1/payment/collection/notify/pay",
+                sent.get("notify_url"), "上送的必须是平台回调入口");
+        assertNotEquals("https://caller.example/their-own-callback", sent.get("notify_url"),
+                "调用方的下游地址绝不能上送(那会让上游绕过我们直连调用方)");
+    }
+
+    /** 反向守卫:平台地址为空 ⇒ **不发这个键**,而不是退回调用方的地址。 */
+    @Test
+    void barcode_blankPlatformNotifyUrl_omitsTheKey() {
+        props.setNotifyUrl("   ");
+        when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        LsyOrderEntity o = order();
+        o.setNotifyUrl("https://caller.example/their-own-callback");
+        orderService.createBarcodeOrder(o, "134567890123456789", "000");
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/pay"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertFalse(sent.containsKey("notify_url"),
+                "空白的平台地址一律不发该键(上游会自动取机构默认配置回调)");
+    }
+
     // ---- settleFromQuery 的契约(Task 16 两个调度器都依赖它) ----
 
     @Test

+ 85 - 8
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCancelServiceTest.java

@@ -50,6 +50,17 @@ class LsyCancelServiceTest {
         return o;
     }
 
+    private LsyOrderEntity h5() {
+        LsyOrderEntity o = new LsyOrderEntity();
+        o.setId(3L);
+        o.setMchNo("Y52211090004");
+        o.setPayScene("H5");
+        o.setPayTraceNo("PT-H5-1");
+        o.setPayTime("20260928162914");
+        o.setStatus("PAYING");
+        return o;
+    }
+
     private Map<String, Object> resp(String resultCode) {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("return_code", "SUCCESS");
@@ -257,20 +268,86 @@ class LsyCancelServiceTest {
         verify(queryService, never()).queryOrder(any());
     }
 
+    /**
+     * H5 单**要**打上游 `/v3/closeorder` —— 原用例
+     * {@code h5_hasNoCloseInterface_returnsExpiredLocally} 断言「一个上游调用都不发」,
+     * 其依据是「上游 H5 目录下无独立关单接口」。
+     *
+     * ★ 2026-09-28 联调证伪。差分对照(同刻两笔同参 H5,只关其中一笔):
+     *     被关的 A:PAY_IN_PROCESS → PAY_FAIL;没动的 B:PAY_IN_PROCESS → PAY_IN_PROCESS。
+     *   即 `/v3/closeorder`(`trade_no` 寻址)对 H5 **确实生效**。
+     *   原文那句「H5 目录下无独立关单接口」字面仍真(142 页文档里 §关单 只有三页:
+     *   196236529 `/v3/closeorder`、196236547 `/v3/qrclose`、196236550 云订单,H5 目录下确实没有),
+     *   但由它推出的「所以只能本地收敛」是**假的** —— 该接口在「支付接口」目录下,正文只写了
+     *   可供「二维码支付、JSAPI支付」,实测对 H5 一样受理。取证读数见
+     *   `logs/_lsy-close-control.txt`。
+     *
+     * 本地收敛那一半没删:上游没给过订单号(下单就没成功)时**没有句柄可关**,仍只能本地收敛。
+     */
     @Test
-    void h5_hasNoCloseInterface_returnsExpiredLocally() {
-        LsyOrderEntity o = new LsyOrderEntity();
-        o.setId(3L);
-        o.setPayScene("H5");
-        o.setStatus("PAYING");
+    void h5_closesUpstreamByTradeNo() {
+        when(client.post(eq("/v3/closeorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
 
-        String target = service.closeOrder(o);
+        LsyOrderEntity o = h5();
+        o.setTradeNo("200001070X4326092816291510011");
+
+        assertEquals("CLOSED", service.closeOrder(o));
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/closeorder"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertEquals("200001070X4326092816291510011", sent.get("trade_no"));
+        assertNotNull(sent.get("close_trace_no"), "close_trace_no 是必填(联调实测缺它回「有必传字段未传」)");
+        assertNotNull(sent.get("close_time"));
+    }
+
+    /**
+     * H5 分支**不得**先查一次。
+     *
+     * 这与被扫分支({@code cancelBarcodeOrder} 的「先查后撤」)**故意不同**,不是漏了:
+     * 那边的查询是为了分辨「撤销会不会变成退款」—— 撤销一旦撤到已付款的单,上游会把钱退给用户,
+     * 是**资金动作**,所以必须查。而 `/v3/closeorder` 的正文没有这层语义
+     * (对比 196236528 撤销接口正文明写「会将此订单资金退还给用户」,196236529 关单接口没有),
+     * 且实测对一笔 PAY_FAIL 的单它直接拒绝(「该订单交易失败,不支持关单」)。
+     *
+     * 反过来,H5 单在**顾客还没付款**时的正常态就是 `PAY_IN_PROCESS`(实测 4 次全是
+     * 「用户支付中」)。若照抄「先查后撤」,这一支会在最该关单的那一刻返回 null ——
+     * 关单按钮变成永远点不动,比原来那个「本地收敛」还糟。
+     */
+    @Test
+    void h5_neverQueriesFirst_PAYINGisTheNormalStateForH5() {
+        when(client.post(eq("/v3/closeorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+
+        LsyOrderEntity o = h5();
+        o.setTradeNo("T-H5-1");
+
+        service.closeOrder(o);
 
-        assertEquals("EXPIRED", target);
-        verify(client, never()).post(anyString(), anyMap());
         verify(queryService, never()).queryOrder(any());
     }
 
+    @Test
+    void h5_withoutTradeNo_convergesLocallyExpired() {
+        // 上游没受理过这一单(下单那一跳就失败了)⇒ 没有句柄可关,只能本地收敛。
+        LsyOrderEntity o = h5();
+
+        assertEquals("EXPIRED", service.closeOrder(o));
+        verify(client, never()).post(anyString(), anyMap());
+    }
+
+    @Test
+    void h5_closeRefusedByUpstream_returnsNull_noStateChange() {
+        // 实测:对已关/交易失败的单,上游回 return_code=SUCCESS + result_code=PAY_FAIL
+        // + 「关单失败:该订单交易失败,不支持关单」。关不成 ⇒ 不改状态,留给下次。
+        when(client.post(eq("/v3/closeorder"), anyMap())).thenReturn(resp("PAY_FAIL"));
+
+        LsyOrderEntity o = h5();
+        o.setTradeNo("T-H5-1");
+
+        assertNull(service.closeOrder(o), "关单失败时不应改状态");
+    }
+
     @Test
     void qrcode_missingCodeUrl_returnsExpiredWithoutCallingUpstream() {
         LsyOrderEntity o = qrcode();

+ 21 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyH5PayTest.java

@@ -26,10 +26,11 @@ class LsyH5PayTest {
     @Mock private LsyOrderMapper orderMapper;
     @Mock private LsyQueryService queryService;
     private LsyOrderService orderService;
+    private LeshouyingProperties props;
 
     @BeforeEach
     void setUp() {
-        LeshouyingProperties props = new LeshouyingProperties();
+        props = new LeshouyingProperties();
         props.setSyncPollTimes(3);
         props.setSyncPollIntervalMs(0);
         // 构造器签名与 Task 5 一致(5 参)
@@ -108,6 +109,25 @@ class LsyH5PayTest {
         assertEquals("20260921120000", sent.get("pay_time"), "必须是 14 位 yyyyMMddHHmmss 串,不是时间对象");
     }
 
+    /** 同 {@code LsyBarcodePayTest#barcode_sendsPlatformNotifyUrl_upstream_notTheCallers}:三条下单腿一条规则。 */
+    @Test
+    void h5_sendsPlatformNotifyUrl_upstream_notTheCallers() {
+        props.setNotifyUrl("https://platform.example/api/v1/payment/collection/notify/pay");
+        when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        LsyOrderEntity o = order();
+        o.setNotifyUrl("https://caller.example/their-own-callback");
+        orderService.createH5Order(o, "300");
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/h5pay"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertEquals("https://platform.example/api/v1/payment/collection/notify/pay",
+                sent.get("notify_url"), "上送的必须是平台回调入口,不是调用方的下游地址");
+    }
+
     @Test
     void h5_unknownResultCode_doesNotWriteTerminalFail() {
         // H5 的未知码比聚合码更危险:用户可能**已经**被跳到支付页并完成了付款,

+ 142 - 52
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyServiceTest.java

@@ -156,6 +156,7 @@ class LsyNotifyServiceTest {
         r.setOrderId(1L);
         r.setRefundNo("R001");
         r.setOutRefundNo("RF001");
+        r.setRefundTraceNo("RT001");
         r.setRefundFee(30L);
         r.setStatus(status);
         return r;
@@ -164,24 +165,48 @@ class LsyNotifyServiceTest {
     /**
      * 退款回调报文 —— 词表与支付侧不同(TRADE_REFUND / TRADE_REFUND_FAIL),见实现类注释。
      *
-     * ★★ **本夹具刻意不含 `refund_no`,因为它在上游报文里不存在**(2026-09-22 实测上游
-     * opendocs「退款异步回调」全页:完整 24 行字段表与请求示例里 `refund_no` /
-     * `out_refund_no` / `refund_trace_no` / `qd_refund_no` **一个都没有**)。
-     * 此前这里塞过一个 `refund_no="R001"` —— 那是**凭空字段**(出自 Task 12 brief 的测试样例,
-     * 不是上游文档),它让报文看上去是对的,配合下面各用例的 `selectOne(any())` 打桩,
-     * 使「定位键恒为 null」这个缺陷**在测试里完全不可观测**。
-     * 摘掉它之后夹具才与真实报文同形;缺陷本身由
-     * {@link #refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull} 正面钉住。
+     * ★★ **本夹具的字段集取自 2026-09-29 那笔实弹退款回调(25 个键,报文原文见
+     * `logs/_lsy-refund-callback-raw.json`),值一律换成测试值。** 复刻的是**结构与语义**,
+     * 不是那笔交易的值。三条读数才是要照抄的部分:
      *
-     * <p>需要 `refund_no` 的用例(审计列宽那条)自行 `put` —— 它测的是**未认证入口的截断**,
-     * 攻击者本来就能塞任意字段,与上游发不发无关。
+     *   - `pay_trace_no` = **本笔退款的** trace,与我们随退款请求发出去的 `refund_trace_no`
+     *     (`LsyRefundService` 里 `IdUtil.fastSimpleUUID()` 生成)逐字符相同 ⇒ 它才是定位键;
+     *   - `trade_type` = `"0"`(我方设计文档 §4.2 记的枚举:0退款/1交易/2云订单关单),
+     *     与支付侧的 `"1"` 互斥;
+     *   - `refund_no` / `out_refund_no` / `refund_trace_no` / `qd_refund_no` **一个都没有**
+     *     —— 上游 opendocs「退款异步回调」全页(24 行字段表 + 请求示例)早有此结论,
+     *     实弹报文与它一致。此前夹具里塞过一个 `refund_no="R001"`,那是**凭空字段**
+     *     (出自 Task 12 brief 的测试样例,不是上游文档),使「定位键恒为 null」这个缺陷
+     *     在测试里完全不可观测。
+     *
+     * <p>需要 `refund_no` / 需要删字段的用例自行增删 —— 那几条测的正是**兜底腿**与
+     * **入口截断**,与上游发不发无关。
      */
     private Map<String, Object> refundBody(String resultCode) {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("return_code", "SUCCESS");
+        m.put("return_msg", "退款成功");
         m.put("result_code", resultCode);
+        m.put("trade_type", "0");
         m.put("trade_no", "T001");
-        m.put("mch_no", "Y52211090004");
+        m.put("td_trade_no", "");
+        m.put("pay_trace_no", "RT001");
+        m.put("o_trade_no", "P20260929001");
+        m.put("qd_trade_no", "Q20260929001");
+        m.put("mch_name", "MCH_NAME");
+        m.put("inst_no", "INST001");
+        m.put("shop_no", "");
+        m.put("shop_name", "");
+        m.put("pay_type", "400");
+        m.put("total_amount", "30");
+        m.put("pay_amount", "-1");
+        m.put("receipt_amount", "-1");
+        m.put("coupon_amount", "0");
+        m.put("discount_amount", "0");
+        m.put("user_id", "U001");
+        m.put("pay_time", "2026-09-29 11:04:12");
+        m.put("end_time", "2026-09-29 11:04:13");
+        m.put("attach", "");
         return m;
     }
 
@@ -303,6 +328,13 @@ class LsyNotifyServiceTest {
         // 不写任何终态、不通知下游;但仍应答上游 SUCCESS(免无限重推)
         verify(orderService, never()).applyPayResult(any(), any(), any());
         verify(callbackService, never()).notifyOrder(any());
+
+        // ★ 顺带当**「入口分流」的阴性对照**:支付腿的未知码不许被分流到退款腿。
+        //   `payBody` 的 trade_type="1"(交易)、result_code 也不是 TRADE_REFUND* ——
+        //   两条判据都不成立。若哪天判据被放宽成「认不出来就当退款」,本条立刻红。
+        //   没有这一句时它照样绿:分流走错了也满足上面两条 never()(退款腿也不写订单终态),
+        //   于是缺陷在测试里不可见。
+        verify(refundMapper, never()).selectOne(any());
     }
 
     @Test
@@ -531,72 +563,130 @@ class LsyNotifyServiceTest {
         verify(callbackService).notifyOrder(any());
     }
 
-    // ================= 退款回调的定位键(2026-09-22,表征 + 阳性对照)=================
+    // ================= 退款回调的定位键(2026-09-29 由实弹报文裁定)=================
 
     /**
-     * ★★ **表征测试:钉住「退款回调定位键恒为 null」这个已知缺口。**
-     *
-     * <p>【缺口是什么】`handleRefundNotify` 按 `params.get("refund_no")` 查退款单,
-     * 而上游退款回调报文里**没有这个字段**(实测上游 opendocs「退款异步回调」全页:
-     * 完整 24 行字段表与请求示例里 `refund_no` / `out_refund_no` / `refund_trace_no` /
-     * `qd_refund_no` 一个都没有)。⇒ 定位**每一次都落空**,走 `REFUND_NOT_FOUND` 后返回,
-     * 该分支之后的一切(三分支词表、幂等闸门、`LATE_REFUND_AFTER_TERMINAL`)在生产上是死代码。
-     *
-     * <p>【为什么这里必须断言 wrapper 而不是断言"没找到"】断言结果没有信息量 ——
-     * `selectOne` 是 mock,返回什么由用例自己 stub。**唯一能证明「键是 null」的读数,
-     * 是去看 wrapper 里绑的值**。这也正是此前全绿的原因:`any()` 把 wrapper 吃掉了。
-     *
-     * <p>【为什么断言的是缺陷而不是断言"应该找到"】改定位键需要先知道上游到底用哪个字段指代
-     * 一笔退款 —— 文档**没有定义** `o_trade_no` / `pay_trace_no` 在退款回调里指「原支付的」
-     * 还是「本笔退款的」,两种读法都自洽且结论相反。按项目铁律**不得猜测**,故挂起。
-     * 本条的作用是让缺口**在测试里可见**:谁改定位键,谁就必须来改本条,
-     * 而不是让一个凭空字段继续把缺陷盖住。
+     * ★★ **主键:报文的 `pay_trace_no` → 表里的 `refund_trace_no`。**
      *
-     * <p>【影响面:为什么挂起是可以接受的】异步退款的**收敛**已由退款对账腿覆盖
-     * (`LsyReconcileScheduler.reconcilePendingRefunds` 按 `status` + `created_time` 扫描,
-     * **不依赖任何上游字段**),所以不存在「钱动了我们不知道」。回调从「唯一收敛通道」
-     * 降级为一条从未通电的死腿。
+     * <p>【在此之前这里钉的是什么】原先是
+     * `refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull` —— 一条**表征测试**,
+     * 钉住「按 `refund_no` 查、而上游报文不含该字段 ⇒ 绑定值恒为 null ⇒ 恒 0 行」这个缺口,
+     * 并记着「重新定位键**挂起,不猜**」。挂起的理由是:文档**没有定义**
+     * `o_trade_no` / `pay_trace_no` 在退款回调里指「原支付的」还是「本笔退款的」,
+     * 两种读法都自洽且结论相反。**这个理由现在被实弹读数解除了**:
+     * 2026-09-29 那笔真实退款(1 分)的回调里 `pay_trace_no = 4e8ceec2b83b44a78efaacd73499b5a9`,
+     * 与我们随退款请求发出去的 `refund_trace_no` **逐字符相同** —— 是我们选的值、由上游原样回传,
+     * 因果闭合,不是同形巧合。报文原文见 `logs/_lsy-refund-callback-raw.json`。
      *
-     * <p>裁定与证据全文见
-     * `docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md` §一。
+     * <p>【为什么断言 wrapper 而不是断言「找到了」】`selectOne` 是 mock,返回什么由用例自己 stub,
+     * 断言结果没有信息量。**唯一能证明「用的是哪一列、绑的哪个值」的读数,是去看 wrapper**。
+     * 这也正是此前全绿的原因:`any()` 把 wrapper 吃掉了。
      */
     @Test
-    void refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull() {
+    void refundNotify_realPayload_locatesRefundByItsTraceNo() {
         when(refundMapper.selectOne(any())).thenReturn(refund("REFUNDING"));
 
-        // refundBody 与真实报文同形 —— 刻意不含 refund_no
         service.handleRefundNotify(signed(refundBody("TRADE_REFUND")), "{}");
 
         AbstractWrapper<?, ?, ?> w = capturedRefundLookup();
-        assertTrue(w.getSqlSegment().contains("refund_no ="),
-                "定位仍走 refund_no 列(本断言的对照;改定位键时本条应红);实测: " + w.getSqlSegment());
-        assertNull(boundValue(w, "refund_no"),
-                "★ 已知缺口:真实报文不含 refund_no ⇒ 绑定值恒为 null ⇒ PG 里 `refund_no = NULL` "
-                        + "恒为 UNKNOWN ⇒ 0 行 ⇒ 退款回调永远定位不到自己的退款单。"
-                        + "若此断言变红,说明定位键已被改动 —— 请一并更新本类的 "
-                        + "refundNotify_payloadCarryingRefundNo_wouldBeUsedAsLookupKey 与台账 §一。");
+        assertTrue(w.getSqlSegment().contains("refund_trace_no ="),
+                "定位必须走 refund_trace_no 列;实测: " + w.getSqlSegment());
+        assertEquals("RT001", boundValue(w, "refund_trace_no"),
+                "绑的必须是报文里的 pay_trace_no —— 它就是我们发出去的那个 refund_trace_no。"
+                        + "若本断言变红,说明定位键又被改了,请一并更新本类夹具与 "
+                        + "docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md §一");
     }
 
     /**
-     * **阳性对照**:证明上面那条断言的「看得见非 null」是成立的。
+     * **兜底腿**:报文里没有 `pay_trace_no` 时回落到 `refund_no`,且**只在它有值时才查**。
      *
-     * <p>没有这一条,`refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull` 读到 null
-     * 可能是**断言机制本身失灵**(正则没匹配上、取值取错括号)—— 那样它就是个假绿。
-     * 这里塞一个上游**不会发**的 `refund_no`,若定位键确实被读出来,值就该是它。
+     * <p>【这条腿为什么保留】`pay_trace_no` 在退款回调里指「本笔退款」这一读法目前只有**一笔**
+     * 实弹样本。若上游在某些场景改发原支付的 trace,主键会落空 —— 那时兜底救不回来
+     * (真实报文里本就没有 `refund_no`),但**也不会更坏**:仍是 `REFUND_NOT_FOUND`。
      *
-     * <p>两条合起来才是完整读数:**机制有效,且真实报文落进的是 null 那一侧。**
+     * <p>【为什么必须 `!= null` 才查】`.eq(col, null)` 在 MP 里**照发谓词**、绑一个真正的 null,
+     * 而 PG 里 `refund_no = NULL` 恒为 UNKNOWN ⇒ **恒 0 行**。拿一个已知查不到的条件去查库,
+     * 除了把意图藏起来没有任何作用。
      */
     @Test
-    void refundNotify_payloadCarryingRefundNo_wouldBeUsedAsLookupKey() {
+    void refundNotify_payloadWithoutPayTraceNo_fallsBackToRefundNo() {
         when(refundMapper.selectOne(any())).thenReturn(refund("REFUNDING"));
 
         Map<String, Object> body = refundBody("TRADE_REFUND");
-        body.put("refund_no", "R001");     // 上游不发此字段,纯粹为了证明断言看得见非 null
+        body.remove("pay_trace_no");        // 真实报文不发这种形态,纯粹为了压兜底这条腿
+        body.put("refund_no", "R001");
 
         service.handleRefundNotify(signed(body), "{}");
 
+        // capturedRefundLookup 内含 verify(...)(等价 times(1))⇒ 主键落空后**只**发一次查询
         assertEquals("R001", boundValue(capturedRefundLookup(), "refund_no"),
-                "断言机制必须能读出非 null 的定位键,否则上面那条的 null 读数不可信");
+                "主键缺失时必须落到 refund_no 兜底,否则这条腿是死的");
+    }
+
+    /**
+     * **两个键都没有 ⇒ 一次库都不查**,直接记 `REFUND_NOT_FOUND`。
+     *
+     * <p>与上一条同源:`refund_no = NULL` 恒 0 行,所以「查了」与「没查」的**结果**完全一样,
+     * 区别只在读数上 —— 发出去的那次查询会让日志/追踪里出现一条看上去像「查过、没找到」的记录,
+     * 而真相是「根本没有可查的键」。这两种情形的处置不同(前者要查上游是不是发了新字段,
+     * 后者要查我们自己的退款单为什么没落库),所以不能混成同一个读数。
+     */
+    @Test
+    void refundNotify_withoutAnyLocatingKey_queriesNothingAndStillAcksSuccess() {
+        Map<String, Object> body = refundBody("TRADE_REFUND");
+        body.remove("pay_trace_no");        // 既无 pay_trace_no(→ refund_trace_no)也无 refund_no
+
+        String resp = service.handleRefundNotify(signed(body), "{}");
+
+        assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp);
+        verify(refundMapper, never()).selectOne(any());
+
+        var captor = ArgumentCaptor.forClass(LsyNotifyLogEntity.class);
+        verify(notifyLogMapper, atLeastOnce()).insert(captor.capture());
+        assertTrue(captor.getAllValues().stream()
+                        .anyMatch(r -> "REFUND_NOT_FOUND".equals(r.getProcessStatus())),
+                "无键可查也必须留痕;实际=" + processStatuses(captor));
+    }
+
+    /**
+     * ★★ **分流**:退款回调打到**支付端点**上时,必须交给退款腿处理。
+     *
+     * <p>【缺陷是什么】上游只认**一个**回调地址(下单时配的 `notify_url`),退款通知也推它;
+     * 而我们的两个端点(`/notify/pay`、`/notify/refund`)是分开的。2026-09-29 实弹实测:
+     * 那笔退款的回调打在 `/notify/pay` 上,`handlePayNotify` 拿**退款的** trace 去查订单
+     * (必然查不到),写一条 `ORDER_NOT_FOUND` 就结束了 —— 退款腿一次都没通电。
+     * 后果不是「钱动了我们不知道」(收敛有对账腿兜底),而是:
+     * ① 退款回调这条**快通道**整条失效;② 每次退款都误写一条误导性的审计行
+     * (「上游推了一笔我们不认识的支付」),把真正该报警的那条信号淹掉。
+     *
+     * <p>【判据为什么是这两个】`result_code` 前缀 `TRADE_REFUND` 是退款侧独有的词表;
+     * `trade_type="0"` 是文档记的退款枚举(0退款/1交易/2云订单关单),与支付侧的 `"1"` 互斥。
+     * 两条都是上游语义、且**都不与支付侧重叠** —— 上面那条未知码用例是本条的阴性对照。
+     */
+    @Test
+    void refundPayloadArrivingAtThePayEndpoint_isRoutedToTheRefundLeg() {
+        when(refundMapper.selectOne(any())).thenReturn(refund("REFUNDING"));
+        when(refundService.applyNotifyResult(any(), any(), any(), any())).thenReturn(true);
+        when(orderMapper.selectById(1L)).thenReturn(order("SUCCESS"));
+        when(refundService.settleOrderAfterRefund(any(), anyLong())).thenReturn(true);
+
+        String resp = service.handlePayNotify(signed(refundBody("TRADE_REFUND")), "{}");
+
+        // ① 走的是退款腿(会去收敛订单),而不是支付腿的「查订单 → 找不到」
+        assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp);
+        verify(orderMapper, never()).selectOne(any());
+        verify(refundService).settleOrderAfterRefund(any(), eq(30L));
+
+        // ② 审计行里不许出现 ORDER_NOT_FOUND,且 RECEIVED 只许一条
+        //    (分流必须在支付侧写 RECEIVED **之前**,否则一次回调写两条 RECEIVED)
+        var captor = ArgumentCaptor.forClass(LsyNotifyLogEntity.class);
+        verify(notifyLogMapper, atLeastOnce()).insert(captor.capture());
+        assertTrue(captor.getAllValues().stream()
+                        .noneMatch(r -> "ORDER_NOT_FOUND".equals(r.getProcessStatus())),
+                "退款回调不得被记成「订单不存在」;实际=" + processStatuses(captor));
+        assertEquals(1, captor.getAllValues().stream()
+                        .filter(r -> "RECEIVED".equals(r.getProcessStatus())).count(),
+                "一次回调只写一条 RECEIVED;实际=" + processStatuses(captor));
     }
 
     @Test

+ 23 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyQrPayTest.java

@@ -94,6 +94,29 @@ class LsyQrPayTest {
         assertEquals("20260921120000", sent.get("pay_time"), "必须是 14 位 yyyyMMddHHmmss 串,不是时间对象");
     }
 
+    /** 同 {@code LsyBarcodePayTest#barcode_sendsPlatformNotifyUrl_upstream_notTheCallers}:三条下单腿一条规则。 */
+    @Test
+    void qr_sendsPlatformNotifyUrl_upstream_notTheCallers() {
+        props.setNotifyUrl("https://platform.example/api/v1/payment/collection/notify/pay");
+        Map<String, Object> resp = new LinkedHashMap<>();
+        resp.put("return_code", "SUCCESS");
+        resp.put("result_code", "PAY_SUCCESS");
+        resp.put("code_url", "https://x");
+        when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp);
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        LsyOrderEntity o = order();
+        o.setNotifyUrl("https://caller.example/their-own-callback");
+        orderService.createQrOrder(o, null);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/qrpay"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertEquals("https://platform.example/api/v1/payment/collection/notify/pay",
+                sent.get("notify_url"), "上送的必须是平台回调入口,不是调用方的下游地址");
+    }
+
     @Test
     void qr_defaultExpireApplied_whenNotSpecified() {
         Map<String, Object> resp = new LinkedHashMap<>();

+ 98 - 0
java/src/test/java/com/payment/platform/module/payment/notification/handler/AccountHandlerTest.java

@@ -0,0 +1,98 @@
+package com.payment.platform.module.payment.notification.handler;
+
+import com.payment.platform.module.payment.account.entity.TransferEntity;
+import com.payment.platform.module.payment.account.mapper.AccountMapper;
+import com.payment.platform.module.payment.account.mapper.TransferMapper;
+import com.payment.platform.module.payment.enterprise.mapper.EnterpriseMapper;
+import com.payment.platform.module.payment.openapi.service.OpenapiService;
+import com.payment.platform.module.payment.points.service.PointsService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * alipay.fund.trans.order.changed 这条腿的字段落库。
+ *
+ * <p>键名与取值逐字取自 2026-09-29 生产库 {@code alipay_notify_log.message} 里那条
+ * 报文(biz_content 展开后的形态)—— 不是照文档或邻类推的:
+ * {@code out_biz_no} 1302345790789783552 / {@code order_id}
+ * 20260929110070100006870072072224 / {@code status} FAIL /
+ * {@code error_code} Ali_Err_111036 /
+ * {@code fail_reason} 该银行账户已冻结,请联系银行客服。
+ *
+ * <p>本类此前**没有任何测试**,症状因此只表现为「生产库里两列是空的」。
+ */
+@ExtendWith(MockitoExtension.class)
+class AccountHandlerTest {
+
+    private static final String METHOD = "alipay.fund.trans.order.changed";
+
+    @Mock private TransferMapper transferMapper;
+    @Mock private AccountMapper accountMapper;
+    @Mock private EnterpriseMapper enterpriseMapper;
+    @Mock private PointsService pointsService;
+    @Mock private OpenapiService openapiService;
+
+    private AccountHandler handler;
+
+    @BeforeEach
+    void setUp() {
+        // 本 handler 的 mapper 走**构造器注入**(@RequiredArgsConstructor),不是 NotifyContext ——
+        // 与同包的 BatchPayHandler 相反。照 BatchPayHandler 的写法会构造不出来。
+        handler = new AccountHandler(transferMapper, accountMapper, enterpriseMapper,
+                pointsService, openapiService);
+    }
+
+    private TransferEntity dealingTransfer() {
+        TransferEntity t = new TransferEntity();
+        t.setOutBizNo("1302345790789783552");
+        t.setStatus("DEALING");
+        when(transferMapper.selectOne(any())).thenReturn(t);
+        return t;
+    }
+
+    @Test
+    void transOrderChanged_fail_writesErrorCodeAndReason() {
+        TransferEntity t = dealingTransfer();
+        Map<String, String> params = new HashMap<>();
+        params.put("out_biz_no", "1302345790789783552");
+        params.put("order_id", "20260929110070100006870072072224");
+        params.put("status", "FAIL");
+        params.put("trans_amount", "1170.00");
+        params.put("error_code", "Ali_Err_111036");
+        params.put("fail_reason", "该银行账户已冻结,请联系银行客服");
+
+        handler.dispatch(METHOD, params);
+
+        assertEquals("FAIL", t.getStatus());
+        assertEquals("Ali_Err_111036", t.getErrorCode());
+        assertEquals("该银行账户已冻结,请联系银行客服", t.getErrorMsg());
+        verify(transferMapper).updateById(t);
+    }
+
+    /** 正对照:成功报文里**没有**这两个键,守卫必须一个字符都不写。 */
+    @Test
+    void transOrderChanged_success_leavesErrorColumnsUntouched() {
+        TransferEntity t = dealingTransfer();
+        Map<String, String> params = new HashMap<>();
+        params.put("out_biz_no", "1302345790789783552");
+        params.put("order_id", "20260929110070100006870072072224");
+        params.put("status", "SUCCESS");
+        params.put("trans_amount", "1170.00");
+
+        handler.dispatch(METHOD, params);
+
+        assertEquals("SUCCESS", t.getStatus());
+        assertNull(t.getErrorCode());
+        assertNull(t.getErrorMsg());
+    }
+}

+ 204 - 0
logs/_lsy-close-control.py

@@ -0,0 +1,204 @@
+# -*- coding: utf-8 -*-
+"""差分对照:/v3/closeorder 到底有没有真的改变 H5 单的状态?
+
+背景:LsyOrderService.java:334 的注释断言
+  「上游『H5支付』目录下无独立关单接口,本类不为 H5 提供关单路径(见 spec §2.3)」
+但第三轮实测:对一笔 H5 单发 /v3/closeorder(trade_no 寻址)→ 回「关单成功」,
+随后 queryorder 由 PAY_IN_PROCESS「用户支付中」变成 PAY_FAIL「支付失败」。
+
+「关单成功」四个字**不足以**证明状态被改了 —— 也可能它只是应了一声。
+所以做差分:**同一时刻建两笔同参 H5 单,只关其中一笔**,再各查一次。
+
+  A 被关  → 若 A=PAY_FAIL 且 B=PAY_IN_PROCESS ⇒ 差分成立,closeorder 真的改了状态。
+  A 被关  → 若 A 与 B 同为 PAY_IN_PROCESS  ⇒ 那个「关单成功」是空话(或尚未生效)。
+  两笔都 FAIL ⇒ 与关单无关(例如该商户的 H5 单本来就迅速过期),此对照组作废。
+
+聚合码侧同法再做一组(/v3/qrclose 的对照),因为那是我方**已在用**的关单腿。
+"""
+import io
+import json
+import os
+import re
+import time
+import uuid
+import urllib.error
+import urllib.request
+from hashlib import md5
+
+YML = "java/src/main/resources/application.yml"
+VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
+OUTDIR = "logs/_lsy-ctl"
+OUT = "logs/_lsy-close-control.txt"
+buf = []
+
+
+def say(s=""):
+    buf.append(s)
+    print(s.encode("ascii", "replace").decode())
+
+
+def read_block(path, top):
+    lines = io.open(path, encoding="utf-8", errors="replace").read().split("\n")
+    start = None
+    for i, ln in enumerate(lines):
+        if re.match(r"^%s:\s*$" % re.escape(top), ln):
+            start = i
+            break
+    if start is None:
+        raise SystemExit("找不到顶层段 %s:" % top)
+    out = {}
+    for j in range(start + 1, len(lines)):
+        ln = lines[j]
+        if ln.strip() == "" or ln.lstrip().startswith("#"):
+            continue
+        if not ln.startswith((" ", "\t")):
+            break
+        m = re.match(r"^\s+([A-Za-z0-9_.\-]+):\s*(.*)$", ln)
+        if m:
+            out[m.group(1)] = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"")
+    return out
+
+
+def resolve(raw):
+    m = VAR.match(raw)
+    if not m:
+        return raw
+    return os.environ.get(m.group(1)) or m.group(2)
+
+
+cfg = read_block(YML, "leshouying")
+BASE = resolve(cfg["base-url"]).rstrip("/")
+INST = resolve(cfg["inst-no"])
+KEY = resolve(cfg["key"])
+MCH = "Y52211090004"
+if not os.path.isdir(OUTDIR):
+    os.makedirs(OUTDIR)
+
+
+def call(path, params, tag, timeout=60):
+    p = dict(params)
+    p.setdefault("inst_no", INST)
+    p.setdefault("mch_no", MCH)
+    A = "&".join("%s=%s" % (k, p[k]) for k in sorted(p) if p[k] is not None)
+    p["sign"] = md5((A + "&key=" + KEY).encode("utf-8")).hexdigest().lower()
+    body = json.dumps(p, ensure_ascii=False).encode("utf-8")
+    req = urllib.request.Request(BASE + path, data=body,
+                                 headers={"Content-Type": "application/json;charset=utf-8"},
+                                 method="POST")
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as r:
+            raw = r.read()
+    except urllib.error.HTTPError as e:
+        raw = e.read()
+    except Exception as e:  # noqa: BLE001
+        say("  !! 量具失效 %s: %r" % (tag, e))
+        return None
+    io.open("%s/%s.bin" % (OUTDIR, tag), "wb").write(raw)
+    resp = json.loads(raw.decode("utf-8"))
+    usign = resp.get("sign")
+    vs = "sign=null"
+    if usign:
+        nn = {k: v for k, v in resp.items() if k != "sign" and v is not None}
+        A2 = "&".join("%s=%s" % (k, nn[k]) for k in sorted(nn))
+        vs = ("验签OK(%d)" % len(nn)
+              if md5((A2 + "&key=" + KEY).encode("utf-8")).hexdigest().lower() == usign
+              else "验签FAIL")
+    say("  %-16s %-18s rc=%-8s ec=%-14s %s | %s"
+        % (tag, path, resp.get("return_code"), resp.get("result_code"),
+           resp.get("return_msg"), vs))
+    return resp
+
+
+def stamp():
+    return time.strftime("%Y%m%d%H%M%S")
+
+
+def uid(p):
+    return "%s%s%s" % (p, time.strftime("%H%M%S"), uuid.uuid4().hex[:6])
+
+
+def q(tag, **kw):
+    r = call("/v3/queryorder", dict(query_trace_no=uuid.uuid4().hex, **kw), tag)
+    return r
+
+
+# ══════════════════════ 对照一:H5 + /v3/closeorder ══════════════════════
+say("=" * 78)
+say("对照一:两笔同参 H5,只关 A —— 判 /v3/closeorder 是否真改状态")
+say("=" * 78)
+ta, pa = uid("hA"), stamp()
+ra = call("/v3/h5pay", {"pay_trace_no": ta, "pay_time": pa, "total_amount": "1",
+                        "pay_type": "300", "order_body": "对照-H5A-待关"}, "h5-A-create")
+tb, pb = uid("hB"), stamp()
+rb = call("/v3/h5pay", {"pay_trace_no": tb, "pay_time": pb, "total_amount": "1",
+                        "pay_type": "300", "order_body": "对照-H5B-不动"}, "h5-B-create")
+A = ra.get("trade_no") if ra else None
+B = rb.get("trade_no") if rb else None
+say("  A.trade_no = %s" % A)
+say("  B.trade_no = %s" % B)
+say()
+say("  ── 关单前的基线(两笔都必须是 PAY_IN_PROCESS,否则对照作废)──")
+qa0 = q("A-baseline", trade_no=A)
+qb0 = q("B-baseline", trade_no=B)
+say()
+say("  ── 只关 A ──")
+call("/v3/closeorder", {"close_trace_no": uid("kA"), "close_time": stamp(),
+                        "trade_no": A}, "close-A")
+say()
+say("  ── 关后各查一次 ──")
+qa1 = q("A-after", trade_no=A)
+qb1 = q("B-after", trade_no=B)
+say()
+say("  判决:")
+say("    A: %s -> %s  (%s)"
+    % (qa0.get("result_code") if qa0 else "?",
+       qa1.get("result_code") if qa1 else "?", qa1.get("return_msg") if qa1 else "?"))
+say("    B: %s -> %s  (%s)"
+    % (qb0.get("result_code") if qb0 else "?",
+       qb1.get("result_code") if qb1 else "?", qb1.get("return_msg") if qb1 else "?"))
+if qa1 and qb1 and qb1.get("result_code") == "PAY_IN_PROCESS" and qa1.get("result_code") == "PAY_FAIL":
+    say("    ⇒ **差分成立**:closeorder 真把 A 从「用户支付中」改成了「支付失败」,")
+    say("      而同一刻同参的 B 仍是「用户支付中」。closeorder 对 H5 有效。")
+elif qa1 and qb1 and qa1.get("result_code") == qb1.get("result_code"):
+    say("    ⇒ 两笔同态 ⇒ closeorder 对 H5 **没有改变状态**(或那个「关单成功」是空话)。")
+else:
+    say("    ⇒ 读数不可判,见上表。")
+say()
+
+# ══════════════════════ 对照二:聚合码 + /v3/qrclose ══════════════════════
+say("=" * 78)
+say("对照二:两笔同参聚合码,只关 A —— 复核我方已在用的 /v3/qrclose")
+say("=" * 78)
+ta2, pa2 = uid("qA"), stamp()
+ra2 = call("/v3/qrpay", {"pay_trace_no": ta2, "pay_time": pa2, "total_amount": "1",
+                         "time_expire": "30", "order_body": "对照-码A-待关"}, "qr-A-create")
+tb2, pb2 = uid("qB"), stamp()
+rb2 = call("/v3/qrpay", {"pay_trace_no": tb2, "pay_time": pb2, "total_amount": "1",
+                         "time_expire": "30", "order_body": "对照-码B-不动"}, "qr-B-create")
+UA = ra2.get("code_url") if ra2 else None
+say("  A.code_url = %s" % UA)
+say("  B.code_url = %s" % (rb2.get("code_url") if rb2 else None))
+say()
+say("  ── 关单前的基线(已知:未扫的聚合码单查不到,此处复核)──")
+qa2 = q("A-qr-baseline", pay_trace_no=ta2, pay_time=pa2)
+qb2 = q("B-qr-baseline", pay_trace_no=tb2, pay_time=pb2)
+say()
+say("  ── 只关 A ──")
+call("/v3/qrclose", {"code_url": UA}, "qrclose-A")
+say()
+say("  ── 关后各查一次 ──")
+qa3 = q("A-qr-after", pay_trace_no=ta2, pay_time=pa2)
+qb3 = q("B-qr-after", pay_trace_no=tb2, pay_time=pb2)
+say("  判决:A 关前/关后 = %s/%s ;B 关前/关后 = %s/%s"
+    % (qa2.get("return_code") if qa2 else "?", qa3.get("return_code") if qa3 else "?",
+       qb2.get("return_code") if qb2 else "?", qb3.get("return_code") if qb3 else "?"))
+say("  (聚合码单在关前关后都查不到 ⇒「查不到」是这个单**种类**的性质,")
+say("    与关单动作无关 —— 正好反证第一轮那条负读数不是关单造的。)")
+say()
+
+say("句柄留档:")
+say("  h5 A/B = %s / %s   (A 已关,B 未动,可用于后续比对)" % (A, B))
+say("  qr A/B = %s / %s" % (ta2, tb2))
+
+io.open(OUT, "w", encoding="utf-8").write("\n".join(buf) + "\n")
+print("report -> %s" % OUT)

+ 50 - 0
logs/_lsy-close-control.txt

@@ -0,0 +1,50 @@
+==============================================================================
+对照一:两笔同参 H5,只关 A —— 判 /v3/closeorder 是否真改状态
+==============================================================================
+  h5-A-create      /v3/h5pay          rc=SUCCESS  ec=PAY_SUCCESS    预支付成功 | 验签OK(13)
+  h5-B-create      /v3/h5pay          rc=SUCCESS  ec=PAY_SUCCESS    预支付成功 | 验签OK(13)
+  A.trade_no = 200001070X4326092816295710013
+  B.trade_no = 200001070X4326092816295710014
+
+  ── 关单前的基线(两笔都必须是 PAY_IN_PROCESS,否则对照作废)──
+  A-baseline       /v3/queryorder     rc=SUCCESS  ec=PAY_IN_PROCESS 用户支付中 | 验签OK(17)
+  B-baseline       /v3/queryorder     rc=SUCCESS  ec=PAY_IN_PROCESS 用户支付中 | 验签OK(17)
+
+  ── 只关 A ──
+  close-A          /v3/closeorder     rc=SUCCESS  ec=PAY_SUCCESS    关单成功 | 验签OK(8)
+
+  ── 关后各查一次 ──
+  A-after          /v3/queryorder     rc=SUCCESS  ec=PAY_FAIL       支付失败 | 验签OK(17)
+  B-after          /v3/queryorder     rc=SUCCESS  ec=PAY_IN_PROCESS 用户支付中 | 验签OK(17)
+
+  判决:
+    A: PAY_IN_PROCESS -> PAY_FAIL  (支付失败)
+    B: PAY_IN_PROCESS -> PAY_IN_PROCESS  (用户支付中)
+    ⇒ **差分成立**:closeorder 真把 A 从「用户支付中」改成了「支付失败」,
+      而同一刻同参的 B 仍是「用户支付中」。closeorder 对 H5 有效。
+
+==============================================================================
+对照二:两笔同参聚合码,只关 A —— 复核我方已在用的 /v3/qrclose
+==============================================================================
+  qr-A-create      /v3/qrpay          rc=SUCCESS  ec=PAY_SUCCESS    聚合码请求成功! | 验签OK(11)
+  qr-B-create      /v3/qrpay          rc=SUCCESS  ec=PAY_SUCCESS    聚合码请求成功! | 验签OK(11)
+  A.code_url = https://test.gdyfsk.com/yfpay/s/s-00006
+  B.code_url = https://test.gdyfsk.com/yfpay/s/s-00007
+
+  ── 关单前的基线(已知:未扫的聚合码单查不到,此处复核)──
+  A-qr-baseline    /v3/queryorder     rc=FAIL     ec=None           订单信息不存在! | 验签OK(2)
+  B-qr-baseline    /v3/queryorder     rc=FAIL     ec=None           订单信息不存在! | 验签OK(2)
+
+  ── 只关 A ──
+  qrclose-A        /v3/qrclose        rc=SUCCESS  ec=PAY_SUCCESS    聚合码主动关单成功! | 验签OK(7)
+
+  ── 关后各查一次 ──
+  A-qr-after       /v3/queryorder     rc=FAIL     ec=None           订单信息不存在! | 验签OK(2)
+  B-qr-after       /v3/queryorder     rc=FAIL     ec=None           订单信息不存在! | 验签OK(2)
+  判决:A 关前/关后 = FAIL/FAIL ;B 关前/关后 = FAIL/FAIL
+  (聚合码单在关前关后都查不到 ⇒「查不到」是这个单**种类**的性质,
+    与关单动作无关 —— 正好反证第一轮那条负读数不是关单造的。)
+
+句柄留档:
+  h5 A/B = 200001070X4326092816295710013 / 200001070X4326092816295710014   (A 已关,B 未动,可用于后续比对)
+  qr A/B = qA162958b7be9c / qB162958c4b4ee

+ 1 - 0
logs/_lsy-ctl/A-after.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"支付失败","result_code":"PAY_FAIL","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"60af5fa062a84411bb43f7527a934e14","pay_trace_no":"hA16295772ae7e","pay_time":"20260928162957","total_amount":"1","end_time":"20260928162958","trade_no":"200001070X4326092816295710013","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":null,"receipt_amount":"0","pay_amount":"1","coupon_amount":null,"discount_amount":"0","sign":"6dffae39128ce16b45953ce4bc702c8c"}

+ 1 - 0
logs/_lsy-ctl/A-baseline.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"用户支付中","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"fc0c4b5066de4d41913364e9de422d1d","pay_trace_no":"hA16295772ae7e","pay_time":"20260928162957","total_amount":"1","end_time":null,"trade_no":"200001070X4326092816295710013","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"USERPAYING","receipt_amount":"0","pay_amount":"1","coupon_amount":null,"discount_amount":"0","sign":"58b0dfa638874aa8849164eedb640c3b"}

+ 1 - 0
logs/_lsy-ctl/A-qr-after.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"end_time":null,"trade_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"a3cb8169d863e438486a6ea0ae72fea7"}

+ 1 - 0
logs/_lsy-ctl/A-qr-baseline.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"end_time":null,"trade_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"a3cb8169d863e438486a6ea0ae72fea7"}

+ 1 - 0
logs/_lsy-ctl/B-after.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"用户支付中","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"eed946052f0b4f7881e99e2f97b65d54","pay_trace_no":"hB162957e2e9d9","pay_time":"20260928162957","total_amount":"1","end_time":null,"trade_no":"200001070X4326092816295710014","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"USERPAYING","receipt_amount":"0","pay_amount":"1","coupon_amount":null,"discount_amount":"0","sign":"aa117bc031a9dee7191240e2146534ce"}

+ 1 - 0
logs/_lsy-ctl/B-baseline.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"用户支付中","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"8b9ecc9140484bcfbc799334e771fcbe","pay_trace_no":"hB162957e2e9d9","pay_time":"20260928162957","total_amount":"1","end_time":null,"trade_no":"200001070X4326092816295710014","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"USERPAYING","receipt_amount":"0","pay_amount":"1","coupon_amount":null,"discount_amount":"0","sign":"42b07f370ff8ba17f4cc0578a34c2a01"}

+ 1 - 0
logs/_lsy-ctl/B-qr-after.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"end_time":null,"trade_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"a3cb8169d863e438486a6ea0ae72fea7"}

+ 1 - 0
logs/_lsy-ctl/B-qr-baseline.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"end_time":null,"trade_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"a3cb8169d863e438486a6ea0ae72fea7"}

+ 1 - 0
logs/_lsy-ctl/close-A.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"关单成功","result_code":"PAY_SUCCESS","mch_no":null,"close_trace_no":"kA162958b50495","close_time":"20260928162958","trade_no":"200001070X4326092816295710013","pay_trace_no":"hA16295772ae7e","pay_time":"20260928162957","sign":"c15271fdf0632354c35007020a4d1fd0"}

+ 1 - 0
logs/_lsy-ctl/h5-A-create.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"预支付成功","result_code":"PAY_SUCCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","pay_trace_no":"hA16295772ae7e","pay_time":"20260928162957","total_amount":"1","trade_no":"200001070X4326092816295710013","appId":null,"timeStamp":null,"nonceStr":null,"packages":null,"signType":null,"paySign":null,"ali_trade_no":null,"attach":null,"code_url":"https://yyfweb.postar.cn/postalpay/p/generateCode/cloud?threeOrderNo=200001070X4326092816295710013&agetId=FWH000012501&orderDate=20260928","sign":"0ca77f1ed563b0da9d08dd4219308d25","trade_state":null,"pay_amount":null,"coupon_amount":null,"receipt_amount":null,"discount_amount":null,"miniapp_data":null,"mem_data":null}

+ 1 - 0
logs/_lsy-ctl/h5-B-create.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"预支付成功","result_code":"PAY_SUCCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","pay_trace_no":"hB162957e2e9d9","pay_time":"20260928162957","total_amount":"1","trade_no":"200001070X4326092816295710014","appId":null,"timeStamp":null,"nonceStr":null,"packages":null,"signType":null,"paySign":null,"ali_trade_no":null,"attach":null,"code_url":"https://yyfweb.postar.cn/postalpay/p/generateCode/cloud?threeOrderNo=200001070X4326092816295710014&agetId=FWH000012501&orderDate=20260928","sign":"9eaeb47c24409fead703cc1261611880","trade_state":null,"pay_amount":null,"coupon_amount":null,"receipt_amount":null,"discount_amount":null,"miniapp_data":null,"mem_data":null}

+ 1 - 0
logs/_lsy-ctl/qr-A-create.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"聚合码请求成功!","result_code":"PAY_SUCCESS","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","pay_trace_no":"qA162958b7be9c","pay_time":"20260928162958","total_amount":"1","code_url":"https://test.gdyfsk.com/yfpay/s/s-00006","sign":"bbbb742d3fb050c6d4b2336033d628b0"}

+ 1 - 0
logs/_lsy-ctl/qr-B-create.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"聚合码请求成功!","result_code":"PAY_SUCCESS","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","pay_trace_no":"qB162958c4b4ee","pay_time":"20260928162958","total_amount":"1","code_url":"https://test.gdyfsk.com/yfpay/s/s-00007","sign":"e261743f481f583fe688b6756601cf1a"}

+ 1 - 0
logs/_lsy-ctl/qrclose-A.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"聚合码主动关单成功!","result_code":"PAY_SUCCESS","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","sign":"58b1cb3946a1dc8ef76182e462d74e0b"}

+ 176 - 0
logs/_lsy-h5pay-probe.py

@@ -0,0 +1,176 @@
+# -*- coding: utf-8 -*-
+"""乐收赢测试环境 **H5 下单** 探针:向上游发一次真实的 /v3/h5pay。
+
+为什么单列一发:被扫(/v3/pay)与主扫(/v3/qrpay)都已真跑过,这是**第三条**
+也是最后一条未碰过的出站腿。字段集不同(必须显式 pay_type,不接受 000 AUTO)。
+
+★ 与生产代码的关系(别把本探针当生产量具):
+  报文逐字段抄自 `LsyOrderService.createH5Order`(2026-09-28 读法,第 316-336 行):
+    mch_no / [shop_no] / pay_type / pay_trace_no / pay_time / total_amount
+    / [order_body] / [attach] / [notify_url]  + inst_no + sign(client 自动补)
+  本探针是**另一支量具**:证明「这个字段集被上游接受」,不是「Java 客户端发的字节被接受」。
+
+★ 不传 notify_url(同上:传了会让上游测试环境回调我们,是有副作用的动作)。
+★ 凭据(GC3/GC5):密钥只从 application.yml 就地读,只打印长度与来源。
+"""
+import json
+import os
+import re
+import sys
+import time
+import urllib.request
+import urllib.error
+from hashlib import md5
+
+YML = "java/src/main/resources/application.yml"
+VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
+OUT = "logs/_lsy-h5pay-probe.txt"
+RESP_BIN = "logs/_lsy-h5pay-resp.bin"
+
+PAY_TYPE = sys.argv[1] if len(sys.argv) > 1 else "300"   # 300 支付宝 / 400 微信
+
+buf = []
+
+
+def say(s=""):
+    buf.append(s)
+    print(s.encode("ascii", "replace").decode())
+
+
+def read_block(path, top):
+    lines = open(path, encoding="utf-8", errors="replace").read().split("\n")
+    start = None
+    for i, ln in enumerate(lines):
+        if re.match(r"^%s:\s*$" % re.escape(top), ln):
+            start = i
+            break
+    if start is None:
+        raise SystemExit("找不到顶层段 %s:" % top)
+    out = {}
+    for j in range(start + 1, len(lines)):
+        ln = lines[j]
+        if ln.strip() == "" or ln.lstrip().startswith("#"):
+            continue
+        if not ln.startswith((" ", "\t")):
+            break
+        m = re.match(r"^\s+([A-Za-z0-9_.\-]+):\s*(.*)$", ln)
+        if m:
+            v = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"")
+            out[m.group(1)] = v
+    return out
+
+
+def resolve(raw):
+    m = VAR.match(raw)
+    if not m:
+        return raw, "application.yml 字面量"
+    var, default = m.group(1), m.group(2)
+    if os.environ.get(var):
+        return os.environ[var], "环境变量 %s" % var
+    return default, "application.yml 默认值(键 %s)" % var
+
+
+cfg = read_block(YML, "leshouying")
+base_url, base_src = resolve(cfg["base-url"])
+inst_no, inst_src = resolve(cfg["inst-no"])
+key, key_src = resolve(cfg["key"])
+
+say("── 取值来源(不含密钥值)──")
+say("  base-url = %s   [%s]" % (base_url, base_src))
+say("  inst-no  = %s             [%s]" % (inst_no, inst_src))
+say("  key      = <len=%d>        [%s]" % (len(key), key_src))
+say()
+
+now = time.strftime("%Y%m%d%H%M%S")
+trace = "probe" + now + "h5"
+payload = {
+    "inst_no": inst_no,
+    "mch_no": "Y52211090004",
+    "pay_type": PAY_TYPE,            # ★ H5 必须显式指定,不接受 000
+    "pay_trace_no": trace,
+    "pay_time": now,
+    "total_amount": "1",
+    "order_body": "联调探针-H5",
+    # shop_no / attach / notify_url 生产侧「非 null 才发」,此处留空不传
+}
+
+A = "&".join("%s=%s" % (k, payload[k]) for k in sorted(payload))
+sign = md5((A + "&key=" + key).encode("utf-8")).hexdigest().lower()
+payload["sign"] = sign
+
+say("── 将发送(不含密钥;sign 打码)──")
+say("  A = %s" % A)
+say()
+body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+shown = json.loads(body.decode("utf-8"))
+shown["sign"] = "<len=%d>" % len(sign)
+say("  body = %s" % json.dumps(shown, ensure_ascii=False, indent=2))
+say()
+say("  字段清单(= 生产 createH5Order 发的集合):%s" % ", ".join(sorted(payload)))
+say()
+
+url = base_url.rstrip("/") + "/v3/h5pay"
+say("── 出站 ──")
+say("  POST %s" % url)
+say("  Content-Type: application/json;charset=utf-8")
+say("  bytes = %d" % len(body))
+say()
+
+req = urllib.request.Request(
+    url, data=body,
+    headers={"Content-Type": "application/json;charset=utf-8"},
+    method="POST",
+)
+try:
+    with urllib.request.urlopen(req, timeout=30) as r:
+        code, raw = r.status, r.read()
+except urllib.error.HTTPError as e:
+    code, raw = e.code, e.read()
+except Exception as e:
+    say("!! 请求未完成: %r" % (e,))
+    open(OUT, "w", encoding="utf-8").write("\n".join(buf))
+    sys.exit(1)
+
+open(RESP_BIN, "wb").write(raw)
+say("── 上游应答 ──")
+say("  http_code = %s" % code)
+say("  bytes     = %d" % len(raw))
+say("  原始字节已落 %s" % RESP_BIN)
+say()
+
+try:
+    resp = json.loads(raw.decode("utf-8"))
+    say("  解码 = utf-8 成功")
+except Exception as e:
+    say("  utf-8 解码失败: %r" % (e,))
+    open(OUT, "w", encoding="utf-8").write("\n".join(buf))
+    sys.exit(1)
+
+for k in sorted(resp):
+    v = resp[k]
+    if v is None:
+        say("  %-16s = null" % k)
+    else:
+        say("  %-16s = %r" % (k, v))
+say()
+
+rkey = resp.get("sign")
+if rkey:
+    nonnull = {k: v for k, v in resp.items() if k != "sign" and v is not None}
+    A2 = "&".join("%s=%s" % (k, nonnull[k]) for k in sorted(nonnull))
+    mine = md5((A2 + "&key=" + key).encode("utf-8")).hexdigest().lower()
+    say("── 入站验签(null 不参与)──")
+    say("  参与签名的字段数 = %d / 应答字段数 %d" % (len(nonnull), len(resp) - 1))
+    say("  A = %s" % A2)
+    say("  重算 == 上游 sign : %s" % (mine == rkey))
+else:
+    say("── 应答里没有 sign 字段 ──")
+say()
+
+say("── 判读线索(脚本不下结论)──")
+say("  return_code = %s" % resp.get("return_code"))
+say("  return_msg  = %s" % resp.get("return_msg"))
+say("  result_code = %s" % resp.get("result_code"))
+say()
+
+open(OUT, "w", encoding="utf-8").write("\n".join(buf))

+ 71 - 0
logs/_lsy-h5pay-probe.txt

@@ -0,0 +1,71 @@
+── 取值来源(不含密钥值)──
+  base-url = https://test.gdyfsk.com/yfpay   [application.yml 字面量]
+  inst-no  = 900112             [application.yml 默认值(键 LESHOUYING_INST_NO)]
+  key      = <len=32>        [application.yml 默认值(键 LESHOUYING_KEY)]
+
+── 将发送(不含密钥;sign 打码)──
+  A = inst_no=900112&mch_no=Y52211090004&order_body=联调探针-H5&pay_time=20260928160450&pay_trace_no=probe20260928160450h5&pay_type=300&total_amount=1
+
+  body = {
+  "inst_no": "900112",
+  "mch_no": "Y52211090004",
+  "pay_type": "300",
+  "pay_trace_no": "probe20260928160450h5",
+  "pay_time": "20260928160450",
+  "total_amount": "1",
+  "order_body": "联调探针-H5",
+  "sign": "<len=32>"
+}
+
+  字段清单(= 生产 createH5Order 发的集合):inst_no, mch_no, order_body, pay_time, pay_trace_no, pay_type, sign, total_amount
+
+── 出站 ──
+  POST https://test.gdyfsk.com/yfpay/v3/h5pay
+  Content-Type: application/json;charset=utf-8
+  bytes = 235
+
+── 上游应答 ──
+  http_code = 200
+  bytes     = 785
+  原始字节已落 logs/_lsy-h5pay-resp.bin
+
+  解码 = utf-8 成功
+  ali_trade_no     = null
+  appId            = null
+  attach           = null
+  code_url         = 'https://yyfweb.postar.cn/postalpay/p/generateCode/cloud?threeOrderNo=200001070X4326092816045010002&agetId=FWH000012501&orderDate=20260928'
+  coupon_amount    = null
+  discount_amount  = null
+  mch_name         = '建行小店基本休息修改'
+  mch_no           = 'Y52211090004'
+  mem_data         = null
+  miniapp_data     = null
+  nonceStr         = null
+  packages         = null
+  paySign          = null
+  pay_amount       = null
+  pay_time         = '20260928160450'
+  pay_trace_no     = 'probe20260928160450h5'
+  pay_type         = '300'
+  receipt_amount   = null
+  result_code      = 'PAY_SUCCESS'
+  return_code      = 'SUCCESS'
+  return_msg       = '预支付成功'
+  shop_name        = ''
+  shop_no          = ''
+  sign             = '261c9e764f3cb8d59a67b486acd2d629'
+  signType         = null
+  timeStamp        = null
+  total_amount     = '1'
+  trade_no         = '200001070X4326092816045010002'
+  trade_state      = null
+
+── 入站验签(null 不参与)──
+  参与签名的字段数 = 13 / 应答字段数 28
+  A = code_url=https://yyfweb.postar.cn/postalpay/p/generateCode/cloud?threeOrderNo=200001070X4326092816045010002&agetId=FWH000012501&orderDate=20260928&mch_name=建行小店基本休息修改&mch_no=Y52211090004&pay_time=20260928160450&pay_trace_no=probe20260928160450h5&pay_type=300&result_code=PAY_SUCCESS&return_code=SUCCESS&return_msg=预支付成功&shop_name=&shop_no=&total_amount=1&trade_no=200001070X4326092816045010002
+  重算 == 上游 sign : True
+
+── 判读线索(脚本不下结论)──
+  return_code = SUCCESS
+  return_msg  = 预支付成功
+  result_code = PAY_SUCCESS

+ 1 - 0
logs/_lsy-h5pay-resp.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"预支付成功","result_code":"PAY_SUCCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","pay_trace_no":"probe20260928160450h5","pay_time":"20260928160450","total_amount":"1","trade_no":"200001070X4326092816045010002","appId":null,"timeStamp":null,"nonceStr":null,"packages":null,"signType":null,"paySign":null,"ali_trade_no":null,"attach":null,"code_url":"https://yyfweb.postar.cn/postalpay/p/generateCode/cloud?threeOrderNo=200001070X4326092816045010002&agetId=FWH000012501&orderDate=20260928","sign":"261c9e764f3cb8d59a67b486acd2d629","trade_state":null,"pay_amount":null,"coupon_amount":null,"receipt_amount":null,"discount_amount":null,"miniapp_data":null,"mem_data":null}

+ 74 - 0
logs/_lsy-key-scan.py

@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+"""提交前体检:logs/ 下的产物里有没有混进机构密钥。
+只打计数与文件名(文件名不是秘密),绝不打印密钥本身或长度以外的信息。"""
+import io, os, re, sys
+
+YML = "java/src/main/resources/application.yml"
+VAR = re.compile(r"^\$\{([A-Za-z0-9_]+):(.*)\}$")
+
+def read_block(path, top):
+    lines = io.open(path, encoding="utf-8", errors="replace").read().split("\n")
+    start = None
+    for i, ln in enumerate(lines):
+        if re.match(r"^%s:\s*$" % re.escape(top), ln):
+            start = i; break
+    if start is None:
+        raise SystemExit("找不到顶层段 %s:" % top)
+    out = {}
+    for j in range(start + 1, len(lines)):
+        ln = lines[j]
+        if ln.strip() == "" or ln.lstrip().startswith("#"):
+            continue
+        if not ln.startswith((" ", "\t")):
+            break
+        m = re.match(r"^\s+([A-Za-z0-9_.\-]+):\s*(.*)$", ln)
+        if m:
+            out[m.group(1)] = re.split(r"\s+#", m.group(2), maxsplit=1)[0].strip().strip("'\"")
+    return out
+
+cfg = read_block(YML, "leshouying")
+raw = cfg.get("key", "")
+m = VAR.match(raw)
+KEY = (os.environ.get(m.group(1)) or m.group(2)) if m else raw
+print("密钥长度 = %d(只打长度)" % len(KEY))
+if len(KEY) < 8:
+    raise SystemExit("密钥太短,无法作为搜索针,体检作废")
+
+needle = KEY.encode("utf-8")
+
+# 正对照:密钥必然在 application.yml 里;扫不到说明扫描器/针是坏的
+def hits(path):
+    try:
+        return io.open(path, "rb").read().count(needle)
+    except Exception:
+        return 0
+
+pos = hits(YML)
+print("正对照 application.yml 命中 = %d  %s" % (pos, "OK" if pos >= 1 else "!! 扫描器失效,体检作废"))
+if pos < 1:
+    raise SystemExit(1)
+
+# ★ 2026-09-28 改成**递归扫全部 logs/_lsy\***,不再用硬编码文件表。
+#   原表漏了后来新增的 logs/_lsy-lc4/ 与几个 query 探针 —— 于是「体检通过」只覆盖了表里那些,
+#   而表和新增产物**各改各的**(同一类失真:量具自己的覆盖面没人量)。
+#   实测过它漏的后果:ed310fc 之后新增的 11 个产物不在表里,体检说「干净」而它们根本没被读过。
+#   递归之后新增产物自动在射程内,这个失效模式整类消失。
+files = []
+for root, _dirs, names in os.walk("logs"):
+    for n in sorted(names):
+        if n.startswith("_lsy"):
+            files.append(os.path.join(root, n))
+files.sort()
+
+bad, n = [], 0
+for f in files:
+    n += 1
+    c = hits(f)
+    if c:
+        bad.append((f, c))
+
+print("扫描文件数 = %d" % n)
+print("含密钥的文件数 = %d" % len(bad))
+for f, c in bad:
+    print("  !! %s  命中 %d 次" % (f, c))
+print("结论: %s" % ("干净,可提交" if not bad else "**有泄漏,不得提交**"))

+ 8 - 0
logs/_lsy-key-scan.txt

@@ -0,0 +1,8 @@
+密钥长度 = 32(只打长度)
+正对照 application.yml 命中 = 1  OK
+扫描文件数 = 82
+含密钥的文件数 = 3
+  !! logs\_lsy-doc-196236523.md  命中 1 次
+  !! logs\_lsy-page-196236523.json  命中 1 次
+  !! logs\_lsy-page-196236523.u8.json  命中 1 次
+结论: **有泄漏,不得提交**

+ 1 - 0
logs/_lsy-lc/cancel-nonexistent.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"cancel_trace_no":null,"cancel_time":null,"trade_no":null,"pay_trace_no":null,"pay_time":null,"sign":null}

+ 1 - 0
logs/_lsy-lc/close-qr-1st.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"聚合码主动关单成功!","result_code":"PAY_SUCCESS","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","sign":"58b1cb3946a1dc8ef76182e462d74e0b"}

+ 1 - 0
logs/_lsy-lc/close-qr-2nd.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"该聚合码链接不存在或已过期!","result_code":"PAY_FAIL","mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"sign":null}

+ 1 - 0
logs/_lsy-lc/close-qr-nocodeurl.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"有必传字段未传!","result_code":"PAY_FAIL","mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"sign":null}

+ 1 - 0
logs/_lsy-lc/close-qr-nonexistent.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"该聚合码链接不存在或已过期!","result_code":"PAY_FAIL","mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"sign":null}

+ 1 - 0
logs/_lsy-lc/closeorder-h5-bytradeno.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"关单成功","result_code":"PAY_SUCCESS","mch_no":null,"close_trace_no":"cl162419724611","close_time":"20260928162419","trade_no":"200001070X4326092816241610002","pay_trace_no":"h5162416152af0","pay_time":"20260928162416","sign":"32fc02d5d72062382675c99bfc22002a"}

+ 1 - 0
logs/_lsy-lc/closeorder-qr-bytrace.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"mch_no":null,"close_trace_no":null,"close_time":null,"trade_no":null,"pay_trace_no":null,"pay_time":null,"sign":null}

+ 1 - 0
logs/_lsy-lc/create-h5pay.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"预支付成功","result_code":"PAY_SUCCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","pay_trace_no":"h5162416152af0","pay_time":"20260928162416","total_amount":"1","trade_no":"200001070X4326092816241610002","appId":null,"timeStamp":null,"nonceStr":null,"packages":null,"signType":null,"paySign":null,"ali_trade_no":null,"attach":null,"code_url":"https://yyfweb.postar.cn/postalpay/p/generateCode/cloud?threeOrderNo=200001070X4326092816241610002&agetId=FWH000012501&orderDate=20260928","sign":"f2fa0f2521ee3498cc250f30af324921","trade_state":null,"pay_amount":null,"coupon_amount":null,"receipt_amount":null,"discount_amount":null,"miniapp_data":null,"mem_data":null}

+ 1 - 0
logs/_lsy-lc/create-pay-badcode.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"未知的条码类型!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"end_time":null,"trade_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"sub_openid":null,"attach":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"invoice_entry":null,"async_payment_mode":null,"sign":null,"trade_state":null}

+ 1 - 0
logs/_lsy-lc/create-pay-barcode.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"支付失败,获取顾客账户信息失败,请顾客刷新付款码后重新收款,如再次收款失败,请联系管理员处理。[SOUNDWAVE_PARSER_FAIL]","result_code":"PAY_FAIL","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","pay_trace_no":"bc16241607d898","pay_time":"20260928162416","total_amount":"1","end_time":"20260928162417","trade_no":"200001070X3126092816241710003","td_trade_no":null,"qd_trade_no":"20260928MM6r8o25","user_id":null,"sub_openid":null,"attach":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"invoice_entry":null,"async_payment_mode":null,"sign":"3111f9ff303584e47a035584ead3fcc1","trade_state":null}

+ 1 - 0
logs/_lsy-lc/create-qrpay-dup.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"商户请求流水号已存在!","result_code":"PAY_FAIL","mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"code_url":null,"sign":null}

+ 1 - 0
logs/_lsy-lc/create-qrpay-full.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"聚合码请求成功!","result_code":"PAY_SUCCESS","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","pay_trace_no":"qf16241670036a","pay_time":"20260928162416","total_amount":"2","code_url":"https://test.gdyfsk.com/yfpay/s/s-00005","sign":"3e31cb7f8b480946f40b810d72d5f81d"}

+ 1 - 0
logs/_lsy-lc/create-qrpay-min.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"聚合码请求成功!","result_code":"PAY_SUCCESS","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","pay_trace_no":"qr162415bb3c38","pay_time":"20260928162415","total_amount":"1","code_url":"https://test.gdyfsk.com/yfpay/s/s-00004","sign":"20bc562a8ecd10d763737ef3779bd456"}

+ 1 - 0
logs/_lsy-lc/query-h5-both-keys.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"用户支付中","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"58c34dd2fb824b1d8213c89d35cbfbf7","pay_trace_no":"h5162416152af0","pay_time":"20260928162416","total_amount":"1","end_time":null,"trade_no":"200001070X4326092816241610002","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"USERPAYING","receipt_amount":"0","pay_amount":"1","coupon_amount":null,"discount_amount":"0","sign":"3e71e753d6e795905c20ca95a8e1e0cd"}

+ 1 - 0
logs/_lsy-lc/query-h5-bytrace.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"用户支付中","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"94b1bfe833864e1890bbcd2c3ec7dfd4","pay_trace_no":"h5162416152af0","pay_time":"20260928162416","total_amount":"1","end_time":null,"trade_no":"200001070X4326092816241610002","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"USERPAYING","receipt_amount":"0","pay_amount":"1","coupon_amount":null,"discount_amount":"0","sign":"1acd6b2f519400c0b6fed789c294cd92"}

+ 1 - 0
logs/_lsy-lc/query-h5-bytradeno.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"用户支付中","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"3d73ef41a43c4b5aa08dc71df09326a5","pay_trace_no":"h5162416152af0","pay_time":"20260928162416","total_amount":"1","end_time":null,"trade_no":"200001070X4326092816241610002","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"USERPAYING","receipt_amount":"0","pay_amount":"1","coupon_amount":null,"discount_amount":"0","sign":"5ee0ded241a12234ea2abc443778009f"}

+ 1 - 0
logs/_lsy-lc/query-h5-traceONLY.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"用户支付中","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"40e441a0bd924baabcb3dd63c4cd87b5","pay_trace_no":"h5162416152af0","pay_time":"20260928162416","total_amount":"1","end_time":null,"trade_no":"200001070X4326092816241610002","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"USERPAYING","receipt_amount":"0","pay_amount":"1","coupon_amount":null,"discount_amount":"0","sign":"1188a114f0d0e9932562a39034280a6c"}

+ 1 - 0
logs/_lsy-lc/query-nokey.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"end_time":null,"trade_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"a3cb8169d863e438486a6ea0ae72fea7"}

+ 1 - 0
logs/_lsy-lc/query-nonexistent.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"end_time":null,"trade_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"a3cb8169d863e438486a6ea0ae72fea7"}

+ 1 - 0
logs/_lsy-lc/query-qrpay-afterclose.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"end_time":null,"trade_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"a3cb8169d863e438486a6ea0ae72fea7"}

+ 1 - 0
logs/_lsy-lc/query-qrpay-unscanned.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"pay_trace_no":null,"pay_time":null,"total_amount":null,"end_time":null,"trade_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"a3cb8169d863e438486a6ea0ae72fea7"}

+ 1 - 0
logs/_lsy-lc/query-supply-bytrace.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"支付成功","result_code":"PAY_SUCCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":null,"mch_no":"Y52211090004","shop_no":null,"query_trace_no":"733e9b82428f4c9c8fff1b8fbda39cab","pay_trace_no":"sup162415362a15","pay_time":"20260928162415","total_amount":"1","end_time":"20260928162415","trade_no":"200001070J1226092816241610001","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"SUCCESS","receipt_amount":"1","pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"fc37d3ae4afa60538f8dd39a508ea542"}

+ 1 - 0
logs/_lsy-lc/query-supply-bytradeno.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"支付成功","result_code":"PAY_SUCCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":null,"mch_no":"Y52211090004","shop_no":null,"query_trace_no":"76b788edea764042852aae39c914e623","pay_trace_no":"sup162415362a15","pay_time":"20260928162415","total_amount":"1","end_time":"20260928162415","trade_no":"200001070J1226092816241610001","td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"trade_state":"SUCCESS","receipt_amount":"1","pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"eb3ca4939f3a72f33cabe953ad0179bd"}

+ 1 - 0
logs/_lsy-lc/queryrefund-byno.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"查询失败:商户号[105000851370940]已注销或待审核","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":null,"mch_no":"Y52211090004","shop_no":null,"query_trace_no":"86d2a7b0913842b0b6934dbe1da5287f","refund_trace_no":"rf162420b076a3","refund_time":"20260928162420","total_amount":"1","refund_amount":"1","refund_settle":null,"trade_no":"200001070J1226092816241610001","refund_no":"092816242010004","td_trade_no":null,"qd_trade_no":"179058386000001","user_id":null,"attach":null,"undiscount_amount":null,"sign":"23354d1d8acde4e7482b9936001a8f93"}

+ 1 - 0
logs/_lsy-lc/queryrefund-bytrace.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"query_trace_no":null,"refund_trace_no":null,"refund_time":null,"total_amount":null,"refund_amount":null,"refund_settle":null,"trade_no":null,"refund_no":null,"td_trade_no":null,"qd_trade_no":null,"user_id":null,"attach":null,"undiscount_amount":null,"sign":null}

+ 1 - 0
logs/_lsy-lc/refund-nonexistent.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"refund_trace_no":null,"pay_time":null,"refund_fee":null,"actual_refund_fee":null,"end_time":null,"refund_no":null,"qd_refund_no":null,"sign":null}

+ 1 - 0
logs/_lsy-lc/refund-overamount.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"退款失败:没有查询到符合条件的记录。","result_code":"PAY_FAIL","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":null,"mch_no":"Y52211090004","shop_no":null,"refund_trace_no":"rx162422b143d5","pay_time":"20260928162422","refund_fee":"999999","actual_refund_fee":null,"end_time":"20260928162423","refund_no":"092816242210005","qd_refund_no":null,"sign":"4a11a24bdfcdace4f5ff1dba38ad6fc5"}

+ 1 - 0
logs/_lsy-lc/refund-supply-bytrace.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"退款失败:没有查询到符合条件的记录。","result_code":"PAY_FAIL","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":null,"mch_no":"Y52211090004","shop_no":null,"refund_trace_no":"rf162420b076a3","pay_time":"20260928162420","refund_fee":"1","actual_refund_fee":null,"end_time":"20260928162422","refund_no":"092816242010004","qd_refund_no":null,"sign":"6dd2b1d6736d861339c367c096b15c44"}

+ 1 - 0
logs/_lsy-lc/refund-unpaid-qrpay.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"refund_trace_no":null,"pay_time":null,"refund_fee":null,"actual_refund_fee":null,"end_time":null,"refund_no":null,"qd_refund_no":null,"sign":null}

+ 1 - 0
logs/_lsy-lc/supply-1.bin

@@ -0,0 +1 @@
+{"code":200,"data":"200001070J1226092816241610001","message":"操作成功"}

+ 1 - 0
logs/_lsy-lc2/cancel-barcode-bytradeno.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"暂不支持撤销!","result_code":null,"cancel_trace_no":null,"cancel_time":null,"trade_no":null,"pay_trace_no":null,"pay_time":null,"sign":"f38faf1c35760e58792dc79d1827e2bb"}

+ 1 - 0
logs/_lsy-lc2/q-barcode-aftercancel.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"支付失败","result_code":"PAY_FAIL","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","query_trace_no":"eaace987b34f41e1998168971cd61a97","pay_trace_no":"bc16241607d898","pay_time":"20260928162416","total_amount":"1","end_time":"20260928162417","trade_no":"200001070X3126092816241710003","td_trade_no":null,"qd_trade_no":"20260928MM6r8o25","user_id":null,"attach":null,"trade_state":null,"receipt_amount":"1","pay_amount":null,"coupon_amount":null,"discount_amount":null,"sign":"78b2cb961b3dbfe96056ac4f41126676"}

+ 1 - 0
logs/_lsy-lc2/qrefund-byno-again.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"查询失败:商户号[105000851370940]已注销或待审核","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":null,"mch_no":"Y52211090004","shop_no":null,"query_trace_no":"d8c3a4dde4264bb9aaf60fad59dbed7d","refund_trace_no":"rf162420b076a3","refund_time":"20260928162420","total_amount":"1","refund_amount":"1","refund_settle":null,"trade_no":"200001070J1226092816241610001","refund_no":"092816242010004","td_trade_no":null,"qd_trade_no":"179058386000001","user_id":null,"attach":null,"undiscount_amount":null,"sign":"f1c9cd630765d9b01826363eb8fd07a0"}

+ 1 - 0
logs/_lsy-lc2/qrefund-bytrace-fixed.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"查询失败:商户号[105000851370940]已注销或待审核","result_code":"PAY_IN_PROCESS","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":null,"mch_no":"Y52211090004","shop_no":null,"query_trace_no":"941e7b65f19a4e8dac2e8d090456f0be","refund_trace_no":"rf162420b076a3","refund_time":"20260928162420","total_amount":"1","refund_amount":"1","refund_settle":null,"trade_no":"200001070J1226092816241610001","refund_no":"092816242010004","td_trade_no":null,"qd_trade_no":"179058386000001","user_id":null,"attach":null,"undiscount_amount":null,"sign":"5f3283ffd0890826751541fa62f74675"}

+ 1 - 0
logs/_lsy-lc2/refund-closed-h5.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"订单信息不存在!","result_code":null,"pay_type":null,"mch_name":null,"shop_name":null,"mch_no":null,"shop_no":null,"refund_trace_no":null,"pay_time":null,"refund_fee":null,"actual_refund_fee":null,"end_time":null,"refund_no":null,"qd_refund_no":null,"sign":null}

+ 1 - 0
logs/_lsy-lc2/refund-tradeno-610001.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"退款失败:没有查询到符合条件的记录。","result_code":"PAY_FAIL","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":null,"mch_no":"Y52211090004","shop_no":null,"refund_trace_no":"rt162632d50747","pay_time":"20260928162632","refund_fee":"1","actual_refund_fee":null,"end_time":"20260928162633","refund_no":"092816263310008","qd_refund_no":null,"sign":"f26f1697cd5286f9c34346130c15355c"}

+ 1 - 0
logs/_lsy-lc2/supply-ch1.bin

@@ -0,0 +1 @@
+{"code":500,"data":{},"message":"商户未配置该通道"}

+ 1 - 0
logs/_lsy-lc2/supply-ch2.bin

@@ -0,0 +1 @@
+{"success":"error","msg":"result returns more than one elements; nested exception is javax.persistence.NonUniqueResultException: result returns more than one elements"}

+ 1 - 0
logs/_lsy-lc2/supply-ch4.bin

@@ -0,0 +1 @@
+{"code":500,"data":{},"message":"商户未配置该通道"}

+ 1 - 0
logs/_lsy-lc2/supply-ch50.bin

@@ -0,0 +1 @@
+{"code":200,"data":"200001070J1226092816253210006","message":"操作成功"}

+ 1 - 0
logs/_lsy-lc2/supply-ch6.bin

@@ -0,0 +1 @@
+{"code":500,"data":{},"message":"商户未配置该通道"}

+ 1 - 0
logs/_lsy-lc2/supply-flag2.bin

@@ -0,0 +1 @@
+{"code":200,"data":"200001070J1226092816263510009","message":"操作成功"}

+ 1 - 0
logs/_lsy-lc3/barcode-for-cancel.bin

@@ -0,0 +1 @@
+{"return_code":"SUCCESS","return_msg":"支付失败,获取顾客账户信息失败,请顾客刷新付款码后重新收款,如再次收款失败,请联系管理员处理。[SOUNDWAVE_PARSER_FAIL]","result_code":"PAY_FAIL","pay_type":"300","mch_name":"建行小店基本休息修改","shop_name":"","mch_no":"Y52211090004","shop_no":"","pay_trace_no":"bc2162917048b5d","pay_time":"20260928162917","total_amount":"1","end_time":"20260928162918","trade_no":"200001070X3126092816291710012","td_trade_no":null,"qd_trade_no":"20260928MM6sl2n9","user_id":null,"sub_openid":null,"attach":null,"receipt_amount":null,"pay_amount":null,"coupon_amount":null,"discount_amount":null,"invoice_entry":null,"async_payment_mode":null,"sign":"05f704eab5ab6372e3b38da1dc3b3e5d","trade_state":null}

+ 1 - 0
logs/_lsy-lc3/cancel-barcode-2nd.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"暂不支持撤销!","result_code":null,"cancel_trace_no":null,"cancel_time":null,"trade_no":null,"pay_trace_no":null,"pay_time":null,"sign":"f38faf1c35760e58792dc79d1827e2bb"}

+ 1 - 0
logs/_lsy-lc3/cancel-barcode-bytrace.bin

@@ -0,0 +1 @@
+{"return_code":"FAIL","return_msg":"暂不支持撤销!","result_code":null,"cancel_trace_no":null,"cancel_time":null,"trade_no":null,"pay_trace_no":null,"pay_time":null,"sign":"f38faf1c35760e58792dc79d1827e2bb"}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно