ソースを参照

fix(退款): 上游调用前预插入 CREATED 行 - 堵住超时重试导致的重复出金(台账 §2.1 主症)

createRefund 原先是「先调上游、再 insert」,两者之间没有事务(刻意如此)。
失效链不依赖任何竞态:post 超时抛异常 ⇒ 本地零行 ⇒ 租户拿同一个 out_refund_no
重试时在途闸门读空表 ⇒ 放行;而 refund_trace_no 每次重新 fastSimpleUUID()、
请求体里也不含 out_refund_no ⇒ 上游收到的是一笔全新退款,它连去重的键都没有
⇒ 两次真实出金。

改动:
- 调上游之前先插入 status='CREATED',之后才 client.post。
  用 CREATED 而不是 REFUNDING:前者 =「记下了,但还不知道上游有没有收到」,
  后者 =「上游确定收到了、等结果」—— 对账时该问的问题不同。
- post 抛异常时 log.error 带上 outRefundNo/refundTraceNo 后原样外抛
  (那行已落库,而在接口返回里看不见,不记就没人能把它和"这单退不了款"连起来)。
- 收到响应(= 上游已受理)补上游身份字段并推进 CREATED → REFUNDING。
  身份字段走新增的私有 persistUpstreamIdentity,不并进 applyNotifyResult ——
  状态只有一个写入口这条约束不破。这三个字段只在响应这一刻可得(回调不带),
  其中 refund_no 是对外 detail.refund_no 的唯一来源。
- applyNotifyResult 的 finish_time 改成只在终态流转时写(上一提交已做,本处承接)。

本次改动引入的新缺口(未修,已如实记入台账):post 抛异常时那行留在 CREATED,
该单在该行被收敛前发不起退款;收敛腿扫得到但没有上界(老缺口)。
方向安全 —— 用可用性换资金安全。

测试:LsyRefundServiceTest 34 绿;全仓 408 绿 / 0 失败(改前 403)。
两向变异实测:插入写 REFUNDING ⇒ 恰好 2 条红;insert 挪回 post 之后 ⇒ 恰好 2 条红
(含 InOrder 那条);无害重排三个赋值 ⇒ 34 条仍全绿。
alphaH 3 週間 前
親
コミット
28c0b65273

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

@@ -113,7 +113,7 @@
 > | `service/LsyNotifyService`(`logNotify` 的 `setRefundNo` 注释) | ✅ **本清单原先漏掉的第 7 处**(只列了 `handleRefundNotify`;`logNotify` 里那句「退款回调的定位键」同样是假话) |
 > | `entity/LsyNotifyLogEntity`(`refundNo` 字段注释) | ✅ 改为「**期望**定位键」;并写明该列可被**未认证的伪造报文**写入(验签失败分支同样写审计行、字段值直接取自请求体)⇒ 非 null 时先怀疑伪造 |
 > | `service/LsyMerchantService`(`requireByMchNo` javadoc) | ✅ 删「退款回调按 `refund_no` 定位」,补「退款回调目前定位不了任何东西」 |
-> | `service/LsyQueryDetailAssembler`(白名单依据列表) | ✅ 留引文、标注括注为假;**白名单结论不变**(它仍是我们要对外叫「退款单号」的值) |
+> | `service/LsyQueryDetailAssembler`(白名单依据列表) | ✅ 留引文、标注括注为假;**白名单结论不变**(它仍是我们要对外叫「退款单号」的值)。**2026-09-22 补**:该处末尾「台账 §一已把它列进**📌 待订正清单**(库里的 `COMMENT ON` 与建表脚本一并待改)」是**第 9 处**——它引用的假括注早改了,但这句交叉引用还停在旧状态(那份清单已换成「✅ 订正记录」,且两处都已改完)。**这处与上面 8 处不同类**:前 8 处是「说假话」,它是「**引用**一句假话并声称它待改」。台账原先那份计数只统计前者,故漏了它 |
 > | `test/.../LsyNotifyServiceTest` | ✅ 夹具摘掉凭空字段;`selectOne(any())` 改为捕获 wrapper 断言绑定值;新增钉住现状的表征测试 + 正对照 |
 > | `db/migration/V1.8`(6 处,全部改动后行号) | ✅ `:98`、`:179` 删掉「/ refund_no」半句;`:231` 上方整段论据重写(`结论保留、理由换掉`);`:254`、`:349` 两条 `COMMENT ON` 去掉假括注;`:340` 上方注明该索引目前**无查询会用到**但保留;`pay_lsy_notify_log.refund_no` 列旁注明两边都落 null ⇒ 本列现状空转 |
 > | `core/tenant/TenantInnerInterceptor`(未认证查询通则的「✓ 全局唯一」清单) | ✅ **第 8 处,原清单也没有**(靠「定位键」这个词表全仓 grep 才捞出来)。它把 `refund_no` 列为**可用**的未认证定位键、并在「只能改用 pay_trace_no / refund_no 这类上游给的键」处再次点名。**唯一性成立**(索引在库),但读者会据此重新推出「退款回调按 refund_no 查」这个已被否定的结论。已就地加 ⚠ 并点明「退款侧目前**没有**可用的上游键」 |
@@ -190,11 +190,40 @@
 
 #### ⏳ 实施进度(2026-09-22)
 
-- **已做**:退款对账腿(见 §2.3 的实施记录)。它让**第二条路径**(第 4 步重试后仍有一行落库的情形)有了收敛通道。
-- **未做(本条的主症)**:`:149` 上游调用**之前**预插入 `CREATED`。这正是超时/重试那条路径的堵法 —— 行不存在,在途闸门与 `uk_lsy_refund_one_pending` 就都没有可拦的东西。
-  ⚠ 预插入只堵得住超时那一半;**回调那一半要换定位键 = R41**(见上面 §2.1 正文与 §3 的 R41)。
+- **已做 ①**:退款对账腿(见 §2.3 的实施记录)。它让**第二条路径**(第 4 步重试后仍有一行落库的情形)有了收敛通道。
+- **已做 ②(本条的主症)**:上游调用**之前**预插入 `CREATED`。
+  `createRefund` 现在先 `refundMapper.insert(entity)`(`status = LsyRefundStatus.CREATED.name()`),
+  **之后**才 `client.post("/v3/refundorder", p)`;收到响应(= 上游已受理)再补上游身份字段并推进
+  `CREATED → REFUNDING`。行不存在 ⇒ 在途闸门与 `uk_lsy_refund_one_pending` 都没有可拦的东西——
+  这一点已经消除。
+  - 预插入用 `CREATED` 而非 `REFUNDING` 的理由:两者语义不同,`CREATED` = 「记下了,但**还不知道上游有没有收到**」,
+    对账时该拿 `refund_trace_no` 去问;`REFUNDING` = 「上游确定收到了、等结果」,该问结果。
+  - 补上游字段(`refund_no` / `qd_refund_no` / `refund_settle`)走**新增的私有方法 `persistUpstreamIdentity`**,
+    不并进 `applyNotifyResult`——那三个列与状态无关,而**退款行的状态只有一个写入口**。
+    这三个字段只在响应这一刻可得(回调报文不带,见 R41),其中 `refund_no` 是对外 `detail.refund_no` 的唯一来源。
+  - **顺带修的一处**:`applyNotifyResult` 原先无条件写 `finish_time`。本次改动让它第一次执行非终态流转,
+    而该列注释是「完成时间(终态)」⇒ 改成 `!PENDING.contains(newStatus)` 才写。
+    否则一笔**还在途**的退款会带上完成时间,事后按 `finish_time IS NULL` 捞未完结退款会漏掉它。
+  - **测试**:`LsyRefundServiceTest` 34 绿;全仓 **408 绿 / 0 失败**(改前 403)。
+    两向变异实测:①插入写成 `REFUNDING` ⇒ 恰好 2 条红(断言插入时状态的两条);
+    ②把 insert 挪回 post 之后 ⇒ 恰好 2 条红(含钉顺序的 `InOrder` 那条);
+    ③无害改动(重排三个身份字段赋值)⇒ 34 条**仍全绿**。
+    ⚠ `refund_上游超时后重试_被在途闸门挡住不再出金` 这条**改前就是绿的**(闸门逻辑本就认 `CREATED`)——
+    它是**表征测试**,钉的是「闸门不许被收窄成只认 REFUNDING」这个反方向,不是红转绿测试。
+- ⚠ **本次改动引入的新缺口(未修,如实记)**:`client.post` 抛异常时,预插入的那行会**留在 `CREATED`**。
+  1. 该订单在该行被收敛前**发不起退款**(在途闸门 + 部分唯一索引都认它);
+  2. 收敛通道是 `LsyReconcileScheduler.reconcilePendingRefunds`(`CREATED ∈ PENDING`,扫得到),
+     但那条腿**没有上界、会被老行饿死**——这是本台账已记的老缺口,不是本次新增的。
+  方向是安全的(宁可拒付,不重复出金)⇒ 这是**用可用性换资金安全**的有意取舍。
+  为让它**能被人看见**,catch 分支打了 `log.error` 并带上 `outRefundNo` / `refundTraceNo`
+  (该行在接口返回里看不见,不记就没人能把「租户说退款失败」与「某行卡在 CREATED」连起来)。
 - **未做**:R41(需上游文档裁定回调报文的字段)。
+  ⚠ 预插入只堵得住超时那一半;**回调那一半要换定位键 = R41**(见上面 §2.1 正文与 §3 的 R41)。
+  本次改动**没有**让回调那条路径变好或变坏:预插入的 `CREATED` 行 `refund_no` 仍为 null
+  (补齐要等同步响应,而回调可能早于它到达)⇒ 回调照样落 `REFUND_NOT_FOUND`。
 - ⚠ **本条的「不得猜测」结论仍然有效**:选哪个定位键只能由上游文档裁定,不许从我们自己的测试夹具体反推。
+- 📌 行号提示:本次改动在 `createRefund` 里插入了约 60 行注释与代码,**本台账上文引用的 `:149` / `:181-182` 等行号已整体下移**;
+  按内容定位(`client.post("/v3/refundorder")` / `refundMapper.insert`),不要按行号。
 
 ### 2.2 ★★ 退款失败的**同步/异步语义不对称** —— 唯一一条可能**重复出金**(承重前提已解除,行为未改)
 

+ 99 - 23
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java

@@ -155,8 +155,30 @@ public class LsyRefundService {
         p.put("pay_time", requestTime);
         p.put("pay_trace_time", order.getPayTime());
 
-        Map<String, Object> resp = client.post("/v3/refundorder", p);
-
+        // ★★ 预插入必须在 `client.post` **之前**(台账 §2.1 的主症)。
+        //   为什么:下一个语句一旦抛异常(超时 / 连接重置),方法就此终止 ——
+        //   旧实现一行都不写,而**上游可能已经受理了这笔退款**。
+        //   后果不是"少记一笔":租户拿同一个 out_refund_no 重试时,在途闸门读的是本地行
+        //   (一行都没有)⇒ 放行;而 refund_trace_no 每次重新 fastSimpleUUID()、
+        //   请求体 `p` 里也**不含** out_refund_no ⇒ 上游收到的是一笔**全新**退款,
+        //   它连去重的键都没有 ⇒ 两次真实出金。
+        //   这一行提前钉住之后,重试会撞上上面 :108 的在途校验
+        //   (CREATED ∈ LsyRefundStatus.PENDING,也 ∈ uk_lsy_refund_one_pending 的谓词)。
+        //
+        //   ★ 为什么是 CREATED 而不是 REFUNDING —— 这两个状态在这里语义不同:
+        //     CREATED   = 「我们记下了,但**还不知道上游有没有收到**」;
+        //     REFUNDING = 「上游确定收到了,等结果」。
+        //     这个区别对来对账的人是有用的:前者该拿 refund_trace_no 去问"你到底见过这笔没有",
+        //     后者该问"它结果怎样"。在此之前 CREATED 在退款侧**零写入点**
+        //     (全模块唯一写 CREATED 的 LeshouyingOpenController 写的是**订单**那张表),
+        //     而索引谓词早已包含它(V1.8:234)。
+        //   ★ 用 .name() 而不是字面量 "CREATED":这个值一旦拼错,行就落在
+        //     uk_lsy_refund_one_pending 的谓词之外,互斥窗口**静默**消失。
+        //
+        //   ⚠ 代价(如实记,未修):下一个语句抛异常时这一行会**留在 CREATED**,
+        //     于是这张单在该行被收敛前发不起退款。方向是安全的(宁可拒付、不重复出金),
+        //     收敛通道是 LsyReconcileScheduler.reconcilePendingRefunds(CREATED ∈ PENDING,
+        //     扫得到),但那条腿**没有上界、会被老行饿死** —— 台账已记的老缺口。
         LsyRefundEntity entity = new LsyRefundEntity();
         entity.setTenantId(order.getTenantId());
         entity.setOrderId(order.getId());
@@ -166,34 +188,61 @@ public class LsyRefundService {
         entity.setRefundFee(refundFee);
         entity.setRefundRequestTime(requestTime);
         entity.setOriginalPayTime(order.getPayTime());
+        entity.setNotifyStatus("PENDING");
+        entity.setNotifyCount(0);
+        // 上游来源的 refund_no / qd_refund_no / refund_settle 此刻**还不知道**,收到响应后补
+        entity.setStatus(LsyRefundStatus.CREATED.name());
+        refundMapper.insert(entity);
+
+        Map<String, Object> resp;
+        try {
+            resp = client.post("/v3/refundorder", p);
+        } catch (RuntimeException e) {
+            // ★ 必须 log.error:这一行**已经落库了**,而它在外面看不见 ——
+            //   调用方只看到接口失败,运营看到的只是"这单暂时退不了款"。
+            //   不打出 outRefundNo / refundTraceNo,事后没人能把这两件事连起来。
+            //   重新抛出:调用方可见行为与本改动之前完全一致(失败还是失败)。
+            //   两个实参都不套 LsyLogSanitizer.flatten:都是我们自己的值
+            //   (outRefundNo 由入口 @Pattern 保证,refundTraceNo 是本方法上面的 fastSimpleUUID);
+            //   err 走 getMessage() 而不是整栈 —— 上游文本的 sanitize 已在 LeshouyingApiException 里做过。
+            log.error("退款上游调用失败, 本地已留 CREATED 行(该单在该行收敛前无法再次退款), "
+                            + "需人工/对账核对上游是否已受理: outRefundNo={}, refundTraceNo={}, err={}",
+                    outRefundNo, refundTraceNo, e.getMessage());
+            throw e;
+        }
+
+        // 收到响应 = 上游已受理 ⇒ 补**上游来源**的字段 + 推进 CREATED → REFUNDING。
+        //   两个动作分开做,因为**退款行的状态只有一个写入口**(applyNotifyResult):
+        //   refund_no / qd_refund_no / refund_settle 与状态无关,不能塞进那个 patch。
+        //   ★ 必须在这一刻落库:它们只出现在这里 —— 退款回调报文不带这三个字段(台账 R41),
+        //   丢在这里就再也补不回来。其中 refund_no 是对外那个「退款单号」。
         entity.setRefundNo(str(resp.get("refund_no")));
         entity.setQdRefundNo(str(resp.get("qd_refund_no")));
         entity.setRefundSettle(parseLong(resp.get("refund_settle")));
-        entity.setUpstreamResultCode(str(resp.get("result_code")));
-        entity.setUpstreamResultMsg(str(resp.get("return_msg")));
-        entity.setNotifyStatus("PENDING");
-        entity.setNotifyCount(0);
-        // 这里刻意保留二元三元式,与 Task 6/7/8 的处理不同 —— 理由是失败方向不同:
-        //   未知 result_code 落到 REFUNDING,而 REFUNDING 属于 LsyRefundStatus.PENDING
-        //   (非终态),所以我们既没有对下游宣称「退款成功」,也没有宣称「退款失败」,
-        //   只是把它留在待收敛状态 + upstreamResultCode 存了原始码可追溯。
-        //   没有「钱已退、单记失败」这种最坏方向 —— 这正是下面未知码分支**只记日志、不落终态**
-        //   的理由(支付侧要拦住是因为它有落 FAIL 的最坏方向,退款侧没有,所以停住就够)。
-        //   ★ 但「没有最坏方向」不等于「可以不记日志」:停在 REFUNDING 的行同时占着
+        persistUpstreamIdentity(entity);
+
+        // 无条件推进(不管 result_code 是成功/失败/在途):上游**回过话**就说明它收到了请求,
+        // 此后本行不再表达"上游可能压根没收到"。PAY_FAIL / 未知码留在 REFUNDING
+        // 是下面两个分支各自的既定语义(R11 / R21),它们在本句之后一个字都不改。
+        // CREATED ∈ PAY_WRITE_FROM,所以这一步能命中;返回 false 只可能是本地写库失败,
+        // applyNotifyResult 自己会 log.info,且随后的终态流转(同样从 CREATED/REFUNDING 出发)
+        // 仍然能成功 —— 故这里不重复告警。
+        applyNotifyResult(entity, str(resp.get("result_code")),
+                str(resp.get("return_msg")), LsyRefundStatus.REFUNDING.name());
+        // ★ 一律先落非终态,同步成功也不能直接落终态。两个理由(形状与 2026-09-21 版相同,
+        //   只是落点从「这里 insert 成 REFUNDING」变成了「上面 insert 成 CREATED + 本句推进」):
+        //   1. uk_lsy_refund_one_pending 的互斥窗口只覆盖 {CREATED, REFUNDING}。
+        //      直接写 SUCCESS 的行不在索引谓词里,并发第二笔退款插进来不会撞约束。
+        //   2. 退款行的状态只有一个写入口(applyNotifyResult),同步路径也不该例外。
+        //      ⇒ 所以上面那句必须先落 CREATED 再走本方法推进,**不能** insert 时直接写终态。
+        //
+        // ★ 「停住」的代价(PAY_FAIL / 未知码留在 REFUNDING 时):该行同时占着
         //   uk_lsy_refund_one_pending 与在途闸门 ⇒ 这张单在它被收敛之前发不起退款。
-        //   ★ 「没有对账会来捞它」这句在 2026-09-22 之后**不成立**:
-        //   LsyReconcileScheduler.reconcilePendingRefunds 会捞非终态行问上游
-        //   (前提:已过 props.reconcileDelaySeconds)。所以「停住」现在是有界等待而非永久卡死 ——
+        //   兜底是 LsyReconcileScheduler.reconcilePendingRefunds(会捞非终态行问上游,
+        //   前提:已过 props.reconcileDelaySeconds)⇒ 有界等待而非永久卡死,
         //   但那只是兜底、且要等一轮;**它不构成把未知码当场归类的许可**。
         //   所以未知码必须走下面的 log.error 分支被人看见(R21 补上,此前是静默的):
         //   兜底要等,人能立刻来看。
-        //
-        // ★ 一律先落非终态,同步成功也不能直接 insert 成 SUCCESS。两个理由:
-        //   1. uk_lsy_refund_one_pending 的互斥窗口只覆盖 {CREATED, REFUNDING}。
-        //      直接写 SUCCESS 的行不在索引谓词里,并发第二笔退款插进来不会撞约束。
-        //   2. 退款行的状态只有一个写入口(applyNotifyResult),同步路径也不该例外。
-        entity.setStatus("REFUNDING");
-        refundMapper.insert(entity);
 
         // ★ 同步成功必须在这里就把订单侧收敛完,不能等回调:
         //   回调到达时本行已是 SUCCESS,不在 PAY_WRITE_FROM 里 → applyNotifyResult 命中 0 行
@@ -389,6 +438,33 @@ public class LsyRefundService {
         return null;
     }
 
+    /**
+     * 把**上游来源**的三个字段补写到预插入的那一行上({@link #createRefund} 专用)。
+     *
+     * <p>【为什么单独一次 UPDATE、不并进 {@link #applyNotifyResult}】那三个列与状态无关,
+     * 而本模块的硬约束是「退款行的状态只有一个写入口」。把身份字段塞进状态补丁会把两件事
+     * 耦合成一件,将来任一侧要变动的理由都不再单独成立。
+     *
+     * <p>【条件只看 id,不看 status】响应到达与对账腿收敛理论上可以交错:若此刻本行已被对账
+     * 推到终态,仍要写这三个字段 —— 它们只是上游标识,写上去是**增加信息**,不会覆盖任何结论。
+     * 加一条 status 条件反而会把这个信息丢掉。
+     *
+     * <p>【为什么必须此刻落库】它们只出现在创建退款的那次响应里;退款回调报文**不带**这三个
+     * 字段(台账 R41 的实测结论)。其中 {@code refund_no} 是对外叫「退款单号」的那个值
+     * ({@code LsyQueryDetailAssembler} 的白名单 {@code detail.refund_no}),丢了就是租户可见的缺口。
+     */
+    private void persistUpstreamIdentity(LsyRefundEntity entity) {
+        LsyRefundEntity patch = new LsyRefundEntity();
+        patch.setRefundNo(entity.getRefundNo());
+        patch.setQdRefundNo(entity.getQdRefundNo());
+        patch.setRefundSettle(entity.getRefundSettle());
+        patch.setUpdatedTime(OffsetDateTime.now());
+
+        UpdateWrapper<LsyRefundEntity> w = new UpdateWrapper<>();
+        w.eq("id", entity.getId());
+        refundMapper.update(patch, w);
+    }
+
     /**
      * 退款结果回写 —— **退款行的唯一状态写入口**,条件更新,受影响行数 = 1 才算真的流转。
      *

+ 126 - 15
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java

@@ -1,5 +1,6 @@
 package com.payment.platform.module.payment.leshouying.service;
 
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
@@ -163,27 +164,107 @@ class LsyRefundServiceTest {
     }
 
     @Test
-    void refund_insertsAsRefundingFirst_neverDirectlyTerminal() {
-        // 必须先进 REFUNDING 再收敛:insert 成 SUCCESS 会让本行落在
-        // uk_lsy_refund_one_pending 的谓词之外,并发第二笔退款撞不上约束。
-        // 这个断言就是在钉住"互斥窗口必须真的存在"。
-        //
-        // ★ 必须用 doAnswer 在**调用那一刻**断言,不能用 ArgumentCaptor。
-        //   captor 存的是**引用**,而 createRefund 返回前会在同一个实例上把状态翻成 SUCCESS
-        //   (applyNotifyResult 是退款行唯一的状态写入口,同步路径也照样走它),
-        //   于是 captor.getValue().getStatus() 在返回之后求值永远得到 SUCCESS ——
-        //   那条断言恒假,与实现有没有先落 REFUNDING 无关。
-        //   这里观测的是 INSERT 那一刻的实参状态,才是"先落非终态"的可验证含义。
+    void refund_预插入CREATED_且早于上游调用() {
+        // ★ 用 InOrder 钉住**顺序**,不只钉「发生过 insert」——
+        //   本次改动的全部意义就在这个顺序上(insert 早于 post)。
+        // ★ insert 那一刻断言状态,不能用 ArgumentCaptor:
+        //   captor 存的是**引用**,而 createRefund 返回前会在同一个实例上把状态推到终态,
+        //   于是 captor.getValue().getStatus() 在返回之后求值永远得到终态 —— 那条断言恒假,
+        //   与实现有没有先插 CREATED 无关。(2026-09-21 那条用例的教训,本次沿用。)
+        // ★ 必须仍是**非终态**:直接插 SUCCESS 的行不在 uk_lsy_refund_one_pending 的谓词里,
+        //   并发第二笔退款撞不上约束 —— 互斥窗口必须真的存在,这是钱的那一侧。
+        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+        doAnswer(inv -> {
+            LsyRefundEntity e = inv.getArgument(0);
+            assertEquals("CREATED", e.getStatus(),
+                    "INSERT 那一刻必须是 CREATED(「上游可能还没收到」),不能是终态");
+            e.setId(77L);   // 模拟 MyBatis-Plus 回填雪花 id:后面两次 update 都按它定位
+            return 1;
+        }).when(refundMapper).insert(any());
+        when(refundMapper.update(any(), any())).thenReturn(1);
+        when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
+
+        service.createRefund(order(), "RF001", 100L);
+
+        org.mockito.InOrder io = inOrder(refundMapper, client);
+        io.verify(refundMapper).insert(any());
+        io.verify(client).post(eq("/v3/refundorder"), anyMap());
+    }
+
+    @Test
+    void refund_上游调用抛异常_本地已留CREATED行且异常继续外抛() {
+        // ★ 台账 §2.1 失效链的第一步:上游超时,旧实现一行都没写,而上游**可能已受理**。
+        //   本用例钉住「行已经落库」——那是下一条重试用例能挡住重复出金的前提。
+        doAnswer(inv -> {
+            ((LsyRefundEntity) inv.getArgument(0)).setId(77L);
+            return 1;
+        }).when(refundMapper).insert(any());
+        when(client.post(eq("/v3/refundorder"), anyMap()))
+                .thenThrow(new LeshouyingApiException("NETWORK_ERROR",
+                        "乐收赢接口调用失败: read timeout", null));
+
+        assertThrows(LeshouyingApiException.class,
+                () -> service.createRefund(order(), "RF001", 100L));
+
+        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        verify(refundMapper).insert(captor.capture());
+        assertEquals("CREATED", captor.getValue().getStatus(),
+                "上游可能已受理 ⇒ 行必须先落库;落终态是错的(我们并不知道结果)");
+        // 没收到响应 ⇒ 不该有任何推进:CREATED 表达的就是「不知道上游收到没有」。
+        verify(refundMapper, never()).update(any(), any());
+        // 订单侧一个字都不许动 —— 钱有没有退出去,我们还不知道。
+        verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
+    }
+
+    @Test
+    void refund_上游超时后重试_被在途闸门挡住不再出金() {
+        // ★★ 这条是 §2.1 预插入**存在的理由**。
+        //   旧实现的失效链(不依赖任何竞态):post 抛异常 ⇒ 本地零行 ⇒
+        //   租户拿同一个 out_refund_no 重试时在途校验读空表 ⇒ 放行;
+        //   而 refund_trace_no 每次重新 fastSimpleUUID()、请求体里也不含 out_refund_no
+        //   ⇒ 上游收到的是一笔**全新**退款,它连去重的键都没有 ⇒ 两次真实出金。
+        //   预插入之后,第一次失败留下的 CREATED 行让第二步撞上在途闸门。
+        LsyRefundEntity stuck = new LsyRefundEntity();
+        stuck.setId(77L);
+        stuck.setStatus("CREATED");      // 第一次 post 抛异常留下的那一行
+        when(refundMapper.selectList(any())).thenReturn(List.of(stuck));
+
+        IllegalStateException ex = assertThrows(IllegalStateException.class,
+                () -> service.createRefund(order(), "RF001", 100L));
+        assertTrue(ex.getMessage().contains("退款正在处理中"), "实得: " + ex.getMessage());
+        // ★ 非空转的关键:第二笔必须**根本没发出去**。
+        //   只断言抛异常的话,一个「抛完异常仍然发请求」的实现也会绿。
+        verify(client, never()).post(anyString(), anyMap());
+    }
+
+    @Test
+    void refund_上游响应里的字段_补写到预插入的那一行() {
+        // refund_no 是**对外叫「退款单号」的那个值**(LsyQueryDetailAssembler 的白名单
+        // detail.refund_no),是租户可见字段。预插入时它还不可知,只在响应这一刻可得 ——
+        // 不在这里落库就永久丢失,因为退款回调报文**不带**这些字段(台账 R41 的实测结论)。
         when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
         doAnswer(inv -> {
-            assertEquals("REFUNDING", ((LsyRefundEntity) inv.getArgument(0)).getStatus(),
-                    "INSERT 那一刻必须是非终态,不能直接落终态");
+            ((LsyRefundEntity) inv.getArgument(0)).setId(77L);
             return 1;
         }).when(refundMapper).insert(any());
         when(refundMapper.update(any(), any())).thenReturn(1);
         when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
 
         service.createRefund(order(), "RF001", 100L);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        // 用 atLeast 而不是精确次数:这条用例关心的是「写了什么」,不是「写了几次」。
+        // 同步成功这条路径上本来就是 3 次(补字段 → REFUNDING → SUCCESS),
+        // 写死次数只会让将来多加一次写就红,与它要钉的事实无关。
+        verify(refundMapper, atLeast(2)).update(captor.capture(), any());
+        // 不按下标取(那会把用例绑死在实现的调用顺序上),按内容找。
+        assertTrue(captor.getAllValues().stream().anyMatch(x -> "R001".equals(x.getRefundNo())),
+                "上游回传的 refund_no 必须落库 —— 它是对外 detail.refund_no 的唯一来源");
+        assertTrue(captor.getAllValues().stream().anyMatch(x -> Long.valueOf(100L).equals(x.getRefundSettle())),
+                "refund_settle 同样只在响应这一刻可得");
+        // 状态推进也必须真的发生(CREATED → REFUNDING)。
+        assertTrue(captor.getAllValues().stream().anyMatch(x -> "REFUNDING".equals(x.getStatus())),
+                "收到响应 = 上游已受理 ⇒ 必须离开 CREATED");
     }
 
     @Test
@@ -226,6 +307,12 @@ class LsyRefundServiceTest {
         when(refundMapper.selectList(any())).thenReturn(List.of(settled));
         when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
         when(refundMapper.insert(any())).thenReturn(1);
+        // ★ 必须 stub update 命中:§2.1 之后状态不再是 insert 那一刻写死的,
+        //   而是走 applyNotifyResult 推进(CREATED → REFUNDING),
+        //   而它**只在条件更新真的命中 1 行时**才回写内存里的实体。
+        //   不 stub 的话 Mockito 返回 0 ⇒ 推进失败 ⇒ 返回的实体停在 CREATED,
+        //   红的是夹具、不是实现。
+        when(refundMapper.update(any(), any())).thenReturn(1);
 
         LsyRefundEntity r = service.createRefund(order(), "RF002", 30L);
 
@@ -236,6 +323,7 @@ class LsyRefundServiceTest {
     void refund_payInProcess_statusIsRefunding() {
         when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
         when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.update(any(), any())).thenReturn(1);   // 同上:推进要真命中才回写实体
 
         LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
 
@@ -254,6 +342,7 @@ class LsyRefundServiceTest {
         fail.put("return_msg", "退款失败");
         when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(fail);
         when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.update(any(), any())).thenReturn(1);   // 推进 CREATED → REFUNDING 要真命中
 
         LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
 
@@ -262,7 +351,19 @@ class LsyRefundServiceTest {
         // 下面两条是让本用例**非空转**的关键:只断言 status 的话,
         // 一个「把 FAIL 写成 REFUNDING」的实现也会绿。
         verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
-        verify(refundMapper, never()).update(any(), any());
+        // ★ 断言「没有落终态」,而不是「没有 update」:§2.1 预插入之后,收到响应必然产生
+        //   两次写 —— 补上游身份字段 + 推进 CREATED → REFUNDING,两次都不是终态。
+        //   旧的 verify(never()).update() 会被这两次误伤(它当初要表达的就是「一个状态都不许写」,
+        //   而现在的正确含义是「不许写**终态**」——释放互斥窗口的是终态那个动作)。
+        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        verify(refundMapper, times(2)).update(captor.capture(), any());
+        // 用 equals 而不是 Set.of(...).contains(...):补上游字段那一次的 patch 里 status 是 null,
+        // 而 Set.of 的不可变集合对 contains(null) 直接抛 NPE(不是返回 false)。
+        assertTrue(captor.getAllValues().stream().noneMatch(
+                        x -> "SUCCESS".equals(x.getStatus()) || "FAIL".equals(x.getStatus())),
+                "PAY_FAIL 不得被落成终态 —— 落 FAIL 会释放 uk_lsy_refund_one_pending 的互斥窗口");
+        assertTrue(captor.getAllValues().stream().anyMatch(x -> "REFUNDING".equals(x.getStatus())),
+                "必须推进到 REFUNDING:上游回过话就说明它收到了这笔请求");
     }
 
     @Test
@@ -285,13 +386,23 @@ class LsyRefundServiceTest {
         unknown.put("return_msg", "成功");
         when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(unknown);
         when(refundMapper.insert(any())).thenReturn(1);
+        when(refundMapper.update(any(), any())).thenReturn(1);   // 推进 CREATED → REFUNDING 要真命中
 
         LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
 
         assertEquals("REFUNDING", r.getStatus());
         // 与 R11 那条同形:只断言 status 的话,一个「把未知码落成终态」的实现也会绿。
         verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
-        verify(refundMapper, never()).update(any(), any());
+        // 同 R11 那条:断言「没有落终态」,不是「没有 update」(§2.1 之后必然有两次非终态写)。
+        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        verify(refundMapper, times(2)).update(captor.capture(), any());
+        // 用 equals 而不是 Set.of(...).contains(...):补上游字段那一次的 patch 里 status 是 null,
+        // 而 Set.of 的不可变集合对 contains(null) 直接抛 NPE(不是返回 false)。
+        assertTrue(captor.getAllValues().stream().noneMatch(
+                        x -> "SUCCESS".equals(x.getStatus()) || "FAIL".equals(x.getStatus())),
+                "未知码不得被落成终态 —— 落任何一侧都是替上游猜方向");
+        assertTrue(captor.getAllValues().stream().anyMatch(x -> "REFUNDING".equals(x.getStatus())),
+                "必须推进到 REFUNDING:上游回过话就说明它收到了这笔请求");
     }
 
     @Test