浏览代码

fix(退款): TRADE_REFUND_FAIL 改为守闸 - 不再释放防重复退款的两把锁(台账 §八 R-1)

同一个上游语义(「这笔退款没成功」)经两条路到达,此前判断相反:
同步应答 PAY_FAIL 只 log.error、不写状态;异步回调 TRADE_REFUND_FAIL 却落 FAIL。
落 FAIL 会**同时**释放 uk_lsy_refund_one_pending 与 createRefund 的在途校验,
而这之后两条补救通道都是死的 —— 对账腿只扫非终态(FAIL 永不被问第二次),
唯一的探测器 LATE_REFUND_AFTER_TERMINAL 在「找不到退款单」早退之后(R41 使
refund_no 恒 null ⇒ 真实流量下不可达)⇒ 判错即永久错,且可重复出金、不可逆。

改法:该分支不再写状态,改为 log.error + 审计行(新词 HELD_FOR_RECONCILE,
18 字符,process_status 是 VARCHAR(32) 放得下)+ 直接返回;退款行留在 REFUNDING,
两把锁继续生效。规则收敛成「退款行的终态只由权威答案写」(settleFromQuery)。
本条不建立在「上游会误报」这个未经证实的假设上 —— 两边代价不对称本身就够。

读数(两向都测):
- 改前 RED:applyNotifyResult(..., TRADE_REFUND_FAIL, null, FAIL),红在正确位置
- 改后 LsyNotifyServiceTest 22/22;全量 412 绿 / 0 失败(改前 411,差 = 新增 1 条)
- 变异 M-A(把守闸精确变回释闸,日志保留)⇒ 恰好 1 条红,正是新用例
- 变异 M-B(无害:换日志措辞 + 交换 log 与审计行先后 + 审计词走局部常量)⇒ 22 全绿

★ 订正台账里我自己写错的一句:§八 R-1 原写「现有一条钉住 TRADE_REFUND_FAIL → FAIL
的用例会红」—— 实测该分支**零覆盖**(字面量全测试树只出现在两处注释里),
本次新写的用例是它的第一条覆盖,不存在「要改的旧用例」。

连带改动(都是文档不再失真,非顺手重构):
- LsyRefundService.applyNotifyResult 注释补:FAIL 现在只剩 settleFromQuery 能传
  (5 个调用点逐个量过:非终态 1 / SUCCESS 3 / FAIL 1)
- V1.8 两处枚举「恰好三条、共 9 个」→ 四条、10 个(该注释自己要求两处同步)
- V1.8 的 process_status 列注释补第 10 个词
- 新增 db/lsy-notify-process-status-comment.sql(纯元数据、幂等、非阻塞,待执行)
- 台账 §2.2 加「✅ 已实施」;§八 表格改成「裁定当时」快照并标注行号已漂
alphaH 3 周之前
父节点
当前提交
e289d6606a

+ 62 - 12
docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md

@@ -225,7 +225,7 @@
 - 📌 行号提示:本次改动在 `createRefund` 里插入了约 60 行注释与代码,**本台账上文引用的 `:149` / `:181-182` 等行号已整体下移**;
   按内容定位(`client.post("/v3/refundorder")` / `refundMapper.insert`),不要按行号。
 
-### 2.2 ★★ 退款失败的**同步/异步语义不对称** —— 唯一一条可能**重复出金**(承重前提已解除,行为未改)
+### 2.2 ★★ 退款失败的**同步/异步语义不对称** —— 唯一一条可能**重复出金**(**已修,2026-09-22**)
 
 | 路径 | 写什么状态 | 在途闸门 |
 |---|---|---|
@@ -238,6 +238,11 @@
 
 #### ✅ 裁定(2026-09-22,逐条重读源码,行号已核对)
 
+> ⚠ **本节行号是裁定时刻的快照,别当现状用。** 本条的落地(下面「✅ 已实施」)
+> 在 `LsyNotifyService.handleRefundNotify` 里加长了约 35 行 ⇒ 该文件**本节之后**的行号
+> 已整体下移;`LsyRefundService` 同理(在其第 508 行附近加了注释)。
+> 与 V1.8 里的同款告诫一样:**按内容定位,别按行号**。
+
 **成立。表里两行都复核无误:**
 
 - **同步失败**:`LsyRefundService:205-261` 两个失败分支(`PAY_FAIL` `:205-230`、未知码 `:231-261`)都**只 `log.error`、不写任何状态** ⇒ 行留在 `REFUNDING` ⇒ 在途闸门**持着**。代码自己写明理由(`:216-218`):「落 FAIL 会**释放互斥窗口**:若上游这个 PAY_FAIL 是偶发误报(钱其实出去了),第二笔退款就真的放行了,方向正好是资金最坏的那一侧。」
@@ -267,7 +272,40 @@
 - 但它同时把「回调丢了」的失败退款推迟到一个 `reconcileDelaySeconds` 之后才有出口,
   且出口依赖上游 `/v3/queryrefund` 真的答得出来(未知码情形对账腿同样不落状态);
 - ⇒ 这是一次**可做但未做的行为变更**,需要单独裁定,本增量只解除它的前提。
-- **仍未做**:`handleRefundNotify` 的 `TRADE_REFUND_FAIL` 依旧写 FAIL 释闸。
+
+#### ✅ 已实施(2026-09-22,裁定 R-1 落地)
+
+`LsyNotifyService.handleRefundNotify` 的 `TRADE_REFUND_FAIL` 分支不再 `outcome = "FAIL"`,
+改为 `log.error` + 审计行(新词 `HELD_FOR_RECONCILE`)+ **直接返回**,不调 `applyNotifyResult`
+⇒ 退款行留在 `REFUNDING`,两把锁继续生效。
+
+| 读数 | 结果 |
+|---|---|
+| 全量测试 | **412 绿 / 0 失败**(改前 411,差 = 新增 1 条) |
+| 变异 M-A(把守闸精确变回释闸:只改「写状态」这一件事,日志保留) | **恰好 1 条红** —— 正是新用例 |
+| 变异 M-B(无害:换日志措辞 + 交换 log 与审计行的先后 + 审计词走局部常量) | **22 全绿**(刻意钝感) |
+| 改前 RED | `applyNotifyResult(..., TRADE_REFUND_FAIL, null, FAIL)` —— 红在正确的位置 |
+
+**★ 一处订正:推翻我此前写下的「有测试要改」。** §八 R-1 的「落地要点」当时写着
+「现有一条钉住 `TRADE_REFUND_FAIL → FAIL` 的用例会红,那正是预期的红」——
+**这句是假的**。实测:`TRADE_REFUND_FAIL` 这个字面量在全测试树里**只出现在两处注释**中
+(`LsyNotifyServiceTest:164`、`LsyRefundServiceTest:562`),没有任何一条用例把
+`TRADE_REFUND_FAIL` 喂进 `handleRefundNotify`;调用点全是 `refundBody("TRADE_REFUND")`。
+⇒ 该分支改前是**零覆盖**,本次新写的用例是它的**第一条**覆盖,不存在「要改的旧用例」。
+(教训与 §约束文档自己会失真同类:我对自己刚写完的台账做计数断言,没有实测。)
+
+**连带改动(都是为了让文档不再失真,不是顺手重构):**
+- `LsyRefundService.applyNotifyResult` 的「三个调用点」注释补一句:**FAIL 现在只剩
+  `settleFromQuery` 一条路能传**。判据是 5 个调用点逐个量过(非终态 1、SUCCESS 3、FAIL 1)。
+- `V1.8` 两处「超过 16 字符的**恰好三条**/去重后共 **9** 个」→ **四条/10 个**
+  (`HELD_FOR_RECONCILE` = 18,是全部 10 个里第三长)。两处**都**改了 ——
+  该注释自己要求「两处必须列同一组取值」。
+- `V1.8` 的 `COMMENT ON COLUMN ... process_status` 词表补上第 10 个词。
+  实测库里 `process_status` **已是 `VARCHAR(32)`**(就地加宽那次确实生效了),
+  10 个词逐一对过列宽,**超宽 0 个**。但库里那句**注释**还是旧的 9 词版
+  ⇒ 已交付 `db/lsy-notify-process-status-comment.sql`(纯元数据、幂等,**待用户执行**)。
+  ⚠ 「库里实际写过哪些 process_status 取值」这一读数**无意义**:`pay_lsy_notify_log` 0 行
+  (空洞为真),别把它当成「这 10 个词都用过」。
 
 ### 2.3 ★ 退款单可能永久卡在 `REFUNDING`(**已修,2026-09-22**)
 
@@ -544,6 +582,14 @@ component / is_frame / is_cache / menu_type / visible / perms / ...`),**本
 > 而业务错误的约定**就是** HTTP 200 + `code`(文档 §3.6 那句「别信 HTTP 状态码」)。
 > **⇒ 断言必须照规格写,不能照印象写**(先打印报文再断言)。修正后 8/8。
 
+### 3.4 `db/lsy-notify-process-status-comment.sql`(**非阻塞**,纯注释同步)
+
+裁定 R-1 新增了第 10 个审计词 `HELD_FOR_RECONCILE`(2026-09-22),
+`pay_lsy_notify_log.process_status` 的**列注释词表**要跟着补一个。
+实测:库里该列**已是 `VARCHAR(32)`**(10 个词逐一对过列宽,超宽 **0** 个)⇒ 宽度**不用改**;
+要改的只有那句注释本身。纯元数据、幂等、不碰数据也不碰索引 ⇒ **不阻塞任何事** ——
+不发也不影响功能,只是查库的人少看到一个词。同款先例:`db/lsy-refund-no-comment-fix.sql`。
+
 ---
 
 ## 四、数据可见性与排查提示(不是 bug,但排查时会踩)
@@ -665,9 +711,13 @@ component / is_frame / is_cache / menu_type / visible / perms / ...`),**本
 
 ### R-1 ★★ §2.2 的 `TRADE_REFUND_FAIL` → **改成守闸**
 
-**结论:改。** 同一个上游语义(「这笔退款没成功」)经两条路到达,现在判断相反:
+**结论:改。** 同一个上游语义(「这笔退款没成功」)经两条路到达,裁定当时判断相反:
+
+> ⚠ 下表第三列是**裁定时刻(2026-09-22)的现状快照**,不是现状 ——
+> 本条**已落地**,异步那一格现在也是守闸(实施记录见 §2.2 的「✅ 已实施」)。
+> 保留原表是因为它记录的正是「当时错在哪」,改掉就看不出这条裁定在修什么了。
 
-| 路径 | 位置 | 现在怎么做 |
+| 路径 | 位置 | 裁定当时怎么做 |
 |---|---|---|
 | 同步应答 `PAY_FAIL` | `LsyRefundService.createRefund:289-314` | **守闸**:只 `log.error`,**不写任何状态**,留在 `REFUNDING` |
 | 异步回调 `TRADE_REFUND_FAIL` | `LsyNotifyService.handleRefundNotify:255-256` | **释闸**:`outcome = "FAIL"` → `applyNotifyResult(..., "FAIL")` |
@@ -702,16 +752,16 @@ component / is_frame / is_cache / menu_type / visible / perms / ...`),**本
 本条不建立在「上游会误报」这个未经证实的假设上,而建立在**依据二**(确定的事实)与
 **依据一**(代价不对称)上。
 
-**落地要点**(未实施):
-- `TRADE_REFUND_FAIL` 分支不再落 FAIL,改为 `log.error`(让人立刻看得见)+ 审计行。
-- 需要**一个新的审计状态词**:`UNKNOWN_RESULT_CODE` 是「不认识这个码」,语义不同,
-  不能复用;`APPLIED` 更不能(本次没有回写)。候选 `HELD_FOR_RECONCILE`
-  (=「扣住,留待对账腿拿权威答案」)。
+**落地要点** → **✅ 已实施(2026-09-22)。实施读数与连带改动见 §2.2 的「✅ 已实施」。**
+- `TRADE_REFUND_FAIL` 分支不再落 FAIL,改为 `log.error` + 审计行 + 直接返回。
+- 审计词取 `HELD_FOR_RECONCILE`(18 字符,`process_status` 是 `VARCHAR(32)`,放得下)。
+  不复用 `UNKNOWN_RESULT_CODE`:那是「不认识这个码」,语义不同(这里恰恰是认识)。
   ⚠ 「不新造上游词汇」那条约束针对的是**上游字段值**(如 `result_code` 的字面量,
   必须文档实证);审计状态词本来就是**我们自己的**词表,新增是允许的。
-- 副作用(是好的):退款行的 `FAIL` 从此**只由一条路径写** —— 对账腿问上游拿到的
-  权威答案(`settleFromQuery:437-439`)。规则收敛成「终态只由权威答案写」。
-- 有测试要改:现有一条钉住 `TRADE_REFUND_FAIL → FAIL` 的用例会红,**那正是预期的红**。
+- 副作用(是好的,已落地):退款行的 `FAIL` 从此**只由一条路径写** —— 对账腿问上游拿到的
+  权威答案(`settleFromQuery`)。规则收敛成「终态只由权威答案写」。
+- ⚠ **本节原先写的「有测试要改:现有一条钉住 `TRADE_REFUND_FAIL → FAIL` 的用例会红」
+  是假的** —— 该分支改前**零覆盖**(字面量只出现在两处注释里)。详见 §2.2 的订正。
 
 ### R-2 `status='SUCCESS'` 而 `trade_no` 为空 → **登记 + 最小可见化**
 

+ 41 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyService.java

@@ -249,11 +249,51 @@ public class LsyNotifyService {
         //   权威答案,而这里是当场猜一个方向。
         //   留在 REFUNDING 则三道锁继续生效,代价是这笔要等到下一轮对账 —— 方向是对的:
         //   「卡住等上游确认」远好于「静默判失败后放行重退」。
+        //   ★★ 2026-09-22 裁定 R-1:这条原则**同样适用于下面的 `TRADE_REFUND_FAIL`**。
+        //   在此之前只有未知码守闸,`TRADE_REFUND_FAIL` 却落 FAIL —— 同一个上游语义经两条路
+        //   到达、判断相反,而落 FAIL 的后果正是上面 (a)(b) 同时放行。
+        //   ⇒ 本方法此后**不再写 FAIL**:退款行的 FAIL 只由权威答案写(对账腿问上游)。
         String outcome;
         if ("TRADE_REFUND".equals(resultCode)) {
             outcome = "SUCCESS";
         } else if ("TRADE_REFUND_FAIL".equals(resultCode)) {
-            outcome = "FAIL";
+            // ★★ 裁定 R-1:**不落 FAIL,改为守闸**(与同步侧的 PAY_FAIL 同向)。
+            //
+            // 【为什么上游明说了失败还不照做】「已知码」只说明**我们知道上游说了什么**,
+            //   不说明**上游说的对**。而这里判错的代价不可逆,且**没有**回头路:
+            //     ① FAIL 不在 `LsyRefundStatus.PENDING` 里 ⇒ 对账腿
+            //        (`LsyReconcileScheduler.reconcilePendingRefunds`)**永远不问第二次**;
+            //     ② 唯一的探测器 `LATE_REFUND_AFTER_TERMINAL`(本方法下方)在「找不到退款单」
+            //        早退之后,而真实报文不含任何退款标识(台账 §一 / R41)⇒ 该分支不可达。
+            //   ⇒ 落 FAIL = 两把锁同时释放 + 两条补救通道都是死的 ⇒ 判错就是
+            //     **永久错 + 这单可以立刻再退一笔**(重复出金)。
+            //   守闸的代价只是这单暂缓退款:对账腿下一轮就会去问上游拿权威答案
+            //   (饿死缺口已于本次修掉 ⇒ 每行 ⌈N/100⌉ 轮内必被问到),且有界、运营当场看得见。
+            //
+            // 【这不是「猜一个方向」,而是拒绝替上游猜】方向由此收敛成一条规则:
+            //   **退款行的终态只由权威答案写** —— 即 `LsyRefundService.settleFromQuery`
+            //   拿到的查询应答。同步侧早就写下了同一条原则(那里连日志措辞都一样)。
+            //
+            // 【本条不建立在「上游会误报」这个未经证实的假设上】上游是否误报与本条无关:
+            //   两边代价不对称本身就足够,不需要先证明上游不可靠。
+            log.error("乐收赢退款回调返回 TRADE_REFUND_FAIL,不落终态、留在 {} "
+                            + "待对账腿取上游权威答案(提示:这单暂发不起第二笔退款): "
+                            + "outRefundNo={}, refundNo={}, return_msg={}",
+                    refund.getStatus(),
+                    refund.getOutRefundNo(),
+                    LsyLogSanitizer.flatten(refundNo),
+                    LsyLogSanitizer.flatten(params.get("return_msg")));
+            // ★ 审计词是新造的**我们自己的**词,不是上游词汇 —— 「不新造上游词汇」那条约束
+            //   针对的是 `result_code` 这类上游字段值(必须有文档实证),审计词本来就是我们的词表。
+            //   不复用 UNKNOWN_RESULT_CODE:那是「不认识这个码」,语义不同(这里恰恰是认识)。
+            //   18 字符,VARCHAR(32) 放得下;`logNotify` 对 `process_status` **不做截断**
+            //   (它是自造词、不是攻击者可控输入),所以词长上限由 V1.8 的列宽守着。
+            //   `error_msg` 存上游的 return_msg:本行**不再**走 applyNotifyResult,
+            //   因而 `upstream_result_msg` 这次不会被更新 —— 事后人工核对要读的那句理由
+            //   只有落在这里才留得住(log.error 会随日志轮转消失)。
+            logNotify(params, rawBody, refund.getTenantId(), "SUCCESS",
+                    "HELD_FOR_RECONCILE", str(params.get("return_msg")));
+            return OK;
         } else {
             outcome = null;
         }

+ 6 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java

@@ -507,6 +507,12 @@ public class LsyRefundService {
         //   `LsyRefundStatus.PENDING` 已经是它的唯一定义,改一处即可,不必再多一个集合跟着漂移。
         //   实际传进来的终态只有 SUCCESS / FAIL(三个调用点:同步成功、回调、对账兜底),
         //   两条路径都有测试钉住。
+        //   ★ 2026-09-22 裁定 R-1 之后:**FAIL 只剩「对账兜底」这一条路能传**
+        //   (`settleFromQuery` —— 上游**查询接口**给出的权威答案)。退款回调侧不再写终态 FAIL:
+        //   它收到 `TRADE_REFUND_FAIL` 时改为扣住不写(见 `LsyNotifyService.handleRefundNotify`)。
+        //   ⇒ 规则收敛成一句:**退款行的终态只由权威答案写**。
+        //   (判据:本方法 5 个调用点逐个量过 —— 非终态 CREATED→REFUNDING 一个,
+        //     终态 SUCCESS 三个、FAIL 一个。按方法名找,别按行号。)
         if (!LsyRefundStatus.PENDING.contains(newStatus)) {
             patch.setFinishTime(OffsetDateTime.now());
         }

+ 31 - 0
java/src/main/resources/db/lsy-notify-process-status-comment.sql

@@ -0,0 +1,31 @@
+-- 列注释同步:把新增的审计词 HELD_FOR_RECONCILE 补进 pay_lsy_notify_log.process_status 的词表
+--
+-- 【为什么需要单独一个文件】
+-- V1.8 里那句是 `COMMENT ON`,**不是** `--` 注释 ⇒ 改 V1.8 的文案**不会**影响已建好的库。
+-- 而 V1.8 已经执行过(2026-09-22 实测:本列注释的原文 9 词版就在库里)。所以必须单独重发。
+-- 同款先例见 lsy-refund-no-comment-fix.sql。
+--
+-- 【改的是什么】
+-- 词表由 9 个增到 10 个,新增 `HELD_FOR_RECONCILE`。它由 2026-09-22 裁定 R-1 引入:
+-- 退款回调收到 `TRADE_REFUND_FAIL` 时**不再落终态 FAIL**,改为扣住不写、留待对账腿
+-- 取上游权威答案(落 FAIL 会同时释放两道防重复退款的锁,而对账腿只扫非终态行 ⇒
+-- 判错即永久错、不可逆)。语义与既有的 `UNKNOWN_RESULT_CODE`(不认识这个码)**不同**,
+-- 故不复用。台账:docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md
+--       §八 R-1。
+--
+-- 【列宽无需改动】本列**已是 VARCHAR(32)**(2026-09-22 实机核对:information_schema 读出 32)。
+-- 新词 18 字符,是全部 10 个词里第三/第四长,放得下 —— 这条**不是**靠推算:
+-- 10 个词逐一对过列宽,超宽的 0 个。
+--
+-- 【安全性】
+-- 纯元数据,**不碰任何数据、不碰任何索引**,可重复执行(COMMENT ON 是幂等的)。
+--
+-- 【执行方式】psql -f 本文件,或用任意客户端跑。执行后可用下面那句核对。
+
+COMMENT ON COLUMN pay_lsy_notify_log.process_status IS '受理结果: RECEIVED/APPLIED/DUPLICATE/REJECTED/ORDER_NOT_FOUND/REFUND_NOT_FOUND/UNKNOWN_RESULT_CODE/LATE_PAYMENT_AFTER_TERMINAL/LATE_REFUND_AFTER_TERMINAL/HELD_FOR_RECONCILE';
+
+-- 核对:应打印出上面那句(含结尾的 HELD_FOR_RECONCILE)
+-- SELECT col_description(a.attrelid, a.attnum)
+--   FROM pg_attribute a
+--  WHERE a.attrelid = 'public.pay_lsy_notify_log'::regclass
+--    AND a.attname  = 'process_status';

+ 14 - 7
java/src/main/resources/db/migration/V1.8__create_pay_lsy_tables.sql

@@ -229,9 +229,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_refund_trace_no      ON pay_lsy_refund
 --   `CREATE TABLE IF NOT EXISTS` 的判据**同样只是关系名**:表已存在 ⇒ **整条语句静默跳过**,
 --   **连列宽都不会更新**。本文件**已经真实发生过一次**:`pay_lsy_notify_log.process_status`
 --   由 `VARCHAR(16)` **就地**改成 `VARCHAR(32)`(改定义、名字没变,与上面那两条索引同类)
---   ⇒ 执行过旧版的库重跑本文件后,库里**仍是 `VARCHAR(16)`**;而生产代码会写入**三个**
+--   ⇒ 执行过旧版的库重跑本文件后,库里**仍是 `VARCHAR(16)`**;而生产代码会写入**四个**
 --   超过 16 字符的字面量(`LATE_PAYMENT_AFTER_TERMINAL` 27、`LATE_REFUND_AFTER_TERMINAL` 26、
---   `UNKNOWN_RESULT_CODE` 19)⇒ PostgreSQL **22001** ⇒
+--   `UNKNOWN_RESULT_CODE` 19、`HELD_FOR_RECONCILE` 18 —— 末条见建表处 `process_status`
+--   那一组枚举)⇒ PostgreSQL **22001** ⇒
 --   被 `logNotify` 的 catch 吞成一行 `log.error` ⇒ **那条审计记录彻底不落库**。
 --   **修法与上面不同:`DROP INDEX` 在这里没用**(删不掉「整条建表语句被跳过」的后果),只能:
 --     ALTER TABLE pay_lsy_notify_log ALTER COLUMN process_status TYPE VARCHAR(32);
@@ -308,18 +309,24 @@ CREATE TABLE IF NOT EXISTS pay_lsy_notify_log (
     raw_body        TEXT,
     verify_status   VARCHAR(16)  NOT NULL,
     -- ★ 宽度必须容得下 LsyNotifyService 会写入的**最长**字面量。
-    --   **超过 16 字符的恰好三条**(2026-09-21 从**唯一写入点**反推:`process_status` 只由
-    --   `logNotify` 的第 5 实参写入,故枚举该实参的**全部**取值 —— 去重后共 9 个):
+    --   **超过 16 字符的恰好四条**(2026-09-21 从**唯一写入点**反推:`process_status` 只由
+    --   `logNotify` 的第 5 实参写入,故枚举该实参的**全部**取值 —— 去重后共 10 个,
+    --   其中第 10 个 `HELD_FOR_RECONCILE` 是 2026-09-22 裁定 R-1 新增的):
     --     LATE_PAYMENT_AFTER_TERMINAL = 27,「账实不符:上游说钱收了、我们账上却是终态」,
     --                                  是整个模块最需要留痕的事件;
     --     LATE_REFUND_AFTER_TERMINAL  = 26,**退款侧同理**:上面已论证部分退款是一等公民,
     --                                  而退款对账腿只扫**非终态**行(LsyRefundStatus.PENDING)、
     --                                  覆盖不到「已落终态的行收到迟到成功」这种差异
     --                                  ⇒ 这条最需要事后追溯的记录更不能丢;
-    --     UNKNOWN_RESULT_CODE         = 19,未知结果码同样必须留痕。
+    --     UNKNOWN_RESULT_CODE         = 19,未知结果码同样必须留痕;
+    --     HELD_FOR_RECONCILE          = 18,**退款回调收到 `TRADE_REFUND_FAIL` 时拒绝落终态**
+    --                                  的留痕(2026-09-22 裁定 R-1):落 FAIL 会同时释放
+    --                                  两道防重复退款的锁,而对账腿只扫非终态行
+    --                                  ⇒ 判错即永久错、不可逆 ⇒ 扣住不写,留待对账腿取权威答案。
+    --                                  属「最需要事后追溯」的一类。
     --   ★ 边界值 REFUND_NOT_FOUND = **16**,**恰好放得下**,不在此列 —— 别拿它当「还有余量」的
     --   依据:它正好等于列宽,多一个字符就会重现下面的失效。
-    --   取 VARCHAR(16) 时上面**三条**超长 ⇒ PostgreSQL 抛 22001 ⇒ logNotify 的 catch 把它吞成
+    --   取 VARCHAR(16) 时上面**四条**超长 ⇒ PostgreSQL 抛 22001 ⇒ logNotify 的 catch 把它吞成
     --   一行 log.error ⇒ **这一行审计记录彻底不落库**,而 log.error 会随日志轮转消失 ——
     --   恰恰是本表存在的全部理由被绕过去了。
     --   (★ 同一个「16 不够」的结论也用在 `uk_lsy_refund_refund_no` 上方那段 IF NOT EXISTS
@@ -365,7 +372,7 @@ COMMENT ON COLUMN pay_lsy_notify_log.pay_trace_no   IS '乐收赢支付流水号
 COMMENT ON COLUMN pay_lsy_notify_log.refund_no      IS '退款回调期望定位键(两条回调路径均无此字段,落 NULL)';
 COMMENT ON COLUMN pay_lsy_notify_log.raw_body       IS '回调原始报文(已脱敏)';
 COMMENT ON COLUMN pay_lsy_notify_log.verify_status  IS '验签结果: SUCCESS/FAIL';
-COMMENT ON COLUMN pay_lsy_notify_log.process_status IS '受理结果: RECEIVED/APPLIED/DUPLICATE/REJECTED/ORDER_NOT_FOUND/REFUND_NOT_FOUND/UNKNOWN_RESULT_CODE/LATE_PAYMENT_AFTER_TERMINAL/LATE_REFUND_AFTER_TERMINAL';
+COMMENT ON COLUMN pay_lsy_notify_log.process_status IS '受理结果: RECEIVED/APPLIED/DUPLICATE/REJECTED/ORDER_NOT_FOUND/REFUND_NOT_FOUND/UNKNOWN_RESULT_CODE/LATE_PAYMENT_AFTER_TERMINAL/LATE_REFUND_AFTER_TERMINAL/HELD_FOR_RECONCILE';
 COMMENT ON COLUMN pay_lsy_notify_log.error_msg      IS '错误信息';
 COMMENT ON COLUMN pay_lsy_notify_log.status         IS '为兼容基类实体而保留,本表不使用';
 COMMENT ON COLUMN pay_lsy_notify_log.description    IS '备注/描述';

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

@@ -1,5 +1,6 @@
 package com.payment.platform.module.payment.leshouying.service;
 
+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;
@@ -619,6 +620,57 @@ class LsyNotifyServiceTest {
         verify(callbackService, never()).notifyOrder(any());
     }
 
+    /**
+     * ★★ 裁定 R-1(2026-09-22):`TRADE_REFUND_FAIL` 必须与同步侧的 `PAY_FAIL` 同向 —— **守闸**。
+     *
+     * <p>同一个上游语义经两条路到达,此前判断相反:同步应答 `PAY_FAIL` 只记 `log.error`、
+     * 不写任何状态(`LsyRefundService.createRefund`),异步回调 `TRADE_REFUND_FAIL` 却落 `FAIL`。
+     * 落 `FAIL` 会**同时**释放两道防重复退款的锁(部分唯一索引 `uk_lsy_refund_one_pending`
+     * 与 `createRefund` 的在途前置校验)⇒ 这一单可以立刻再退一笔。
+     *
+     * <p>而释放之后**两条补救通道都是死的**:对账腿只扫非终态(`LsyRefundStatus.PENDING`)
+     * ⇒ FAIL 行永不被问第二次;唯一的探测器 `LATE_REFUND_AFTER_TERMINAL` 在
+     * 「找不到退款单」早退之后,而真实报文不含任何退款标识(R41)⇒ 该分支不可达。
+     * ⇒ 一旦判错就永远错,且是**重复出金**。守闸的代价只是「这单暂缓退款,等对账腿
+     * 拿上游的权威答案」,有界且看得见。
+     *
+     * <p>本条**不**建立在「上游会误报」这个未经证实的假设上 —— 上游是否误报与它无关。
+     */
+    @Test
+    void refundNotify_refundFail_holdsInsteadOfWritingFail() {
+        withLogCapture(LsyNotifyService.class, events -> {
+            when(refundMapper.selectOne(any())).thenReturn(refund("REFUNDING"));
+
+            String resp = service.handleRefundNotify(signed(refundBody("TRADE_REFUND_FAIL")), "{}");
+
+            // 仍应答 SUCCESS:与其它分支同一取舍,免得上游无限重推(重推也改变不了结论)
+            assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp);
+
+            // ★ 核心断言:不写任何状态 ⇒ 两把锁继续生效、这单发不起第二笔退款
+            verify(refundService, never()).applyNotifyResult(any(), any(), any(), any());
+            verify(refundService, never()).settleOrderAfterRefund(any(), anyLong());
+            verify(callbackService, never()).notifyOrder(any());
+
+            // ★ 正对照(没有它本条可能**空过**):HELD_FOR_RECONCILE 只能在
+            //   「查得到退款单」之后才写得出来 —— 若 selectOne 返回 null,
+            //   控制流在 REFUND_NOT_FOUND 处就返回了,上面三条 never() 会**因为错误的理由**成立。
+            //   所以必须钉住「控制流真的走到了 result_code 分派」。
+            var captor = ArgumentCaptor.forClass(LsyNotifyLogEntity.class);
+            verify(notifyLogMapper, atLeastOnce()).insert(captor.capture());
+            assertTrue(captor.getAllValues().stream()
+                            .anyMatch(r -> "HELD_FOR_RECONCILE".equals(r.getProcessStatus())),
+                    "守闸必须留下 HELD_FOR_RECONCILE 审计行(且它证明流程走过了查单那一步);"
+                            + "实际=" + processStatuses(captor));
+
+            // 必须**当场**响且带 outRefundNo:审计表是事后翻的,而部分退款一单多笔,
+            // 只带订单号定位不到具体哪一笔;对账腿又只扫非终态行 ⇒ 这条日志是唯一的人工入口。
+            // 钉住 ERROR 级别本身:降级成 log.info 会让值班在日志里看不到它。
+            assertTrue(events.stream().anyMatch(e -> e.getLevel() == Level.ERROR
+                            && e.getFormattedMessage().contains("RF001")),
+                    "守闸必须打一条带 outRefundNo 的 log.error;实际事件=" + events);
+        });
+    }
+
     @Test
     void signIsExcludedFromVerification_recomputeSucceeds() {
         Map<String, Object> body = signed(payBody("PAY_SUCCESS"));