Przeglądaj źródła

fix(乐收赢): 退款「实退(元)」永远是空的 —— 那个字段只在查询那条腿上有

用户报障:退款记录页的「实退(元)」一列没有数据。

根因不是显示,是这一列**从来没被写过**:`refund_settle` 的两个写入点都从
**退款接口**(/v3/refundorder)的响应里读同名字段,而该接口的响应字段表
(showdoc 196236527)**没有**这个字段 —— 它出现在**退款查询**(/v3/queryrefund)
的应答里(真实键集见 logs/_lsy-refund-query-probe.txt,真打上游)。于是读到的恒是
null,MyBatis-Plus 的 NOT_NULL 策略又把 null 列从 SET 子句里静默丢掉 ⇒ 从 insert
起就是 NULL,全程零日志。

为什么一直没被发现:既有用例 `refund_上游响应里的字段_补写到预插入的那一行` 的夹具
(`resp()`,第 58 行)**自己往退款接口响应里塞了 refund_settle="100"** —— 一种上游
从不发出的报文形状。夹具当初要是照真实键集写,这个缺陷在写下它的那一刻就该红。

四处改动:

1. `LsyRefundService#settleFromQuery`:在 PAY_SUCCESS 分支第一行落 refund_settle。
   位置在 `applyNotifyResult` **之前** —— 它是上游的事实,与「谁写的那次状态流转」
   无关;回调抢先写终态时下面会 return null,但那不构成丢掉实退的理由。
   只认 PAY_SUCCESS:在途/未知码两支保持「一次库都不写」的既有不变量(两条
   `never()` 断言钉着它),而实退是**已结**的事实。

2. `LsyRefundService#backfillSettle` + `LsyReconcileScheduler#backfillRefundSettle`:
   一条**独立**的补写腿,照顾两类永远不会再进 PENDING 谓词的行 —— 同步成功
   (createRefund 返回前就已终态)与回调先到(回调推终态后即失去候选资格)。
   候选集 status=SUCCESS ∩ refund_settle IS NULL ∩ finish_time >= now-7d,
   按 finish_time 升序 LIMIT 100。**窗口是这条腿唯一的出口**(上游始终不回这个字段的
   行否则被无限重问);它不推游标,所以 PENDING 那条腿的「非单调防饿死」论证与那批
   钉它的用例一个字都没动。补写通道不写状态,`backfillSettle` 也够不着
   `applyNotifyResult`。

3. `LsyQueryDetailAssembler#forRefund`:对外叫 refund_fee 的那一格改从 refund_amount 取。
   同一个病:refund_fee 是**退款接口**的词,这条腿从不下发 ⇒ 该格恒为 null,
   而对外契约(collection-openapi.md)一直写着它会给「退款金额(分)」。
   对外**键名不变**(改名是另一次契约变更),只换取值来源。

4. V1.8 的列注释「上游返回,原样落库(本侧不读)」下半句现在是假的,改掉。
   ⚠ 它是 COMMENT ON(不是 -- 注释)⇒ 已建好的库要重发一次才同步,本批**未**附重发脚本。

验证:全量 955 绿。五个变异各自只红一个用例、且红的正是对应的那条:
  删掉落库调用 ⇒ settleFromQuery_退款查询带refund_settle_落库
  refund_amount 改回 refund_fee ⇒ refundDetail_exposesExactlyTheWhitelist
  isNull 改 isNotNull ⇒ backfillRefundSettle_扫描条件为成功且实退为空且在窗口内
  窗口方向 ge→le ⇒ 同上(窗口那一句)
  摘掉 reconcile 的接线 ⇒ reconcile_已接入实退补写腿
反向无害变异(局部变量改名)实测仍绿,并用 class 文件 mtime + 大小(16698 → 16801 B)
证明变异体真的被重新编译过 —— 不是「跑的是上一版 class」。
三个生产文件在变异后均按 sha256 复核回到基线。
alphaH 2 tygodni temu
rodzic
commit
0758748fe7

+ 75 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java

@@ -129,6 +129,16 @@ public class LsyReconcileScheduler {
      */
     private static final String NOT_FOUND_MSG = "订单信息不存在";
 
+    /**
+     * 实退补写窗口:只看最近这么多天内**结单**的退款(见 {@link #backfillRefundSettle})。
+     *
+     * <p>它同时是那条腿**唯一的出口**。候选集的条件里「实退为空」是个**能被上游无限期满足**
+     * 的条件(文档里 `refund_settle` 是可空项:无优惠时可能根本不回),所以必须有一样东西
+     * 把老行赶出去,否则这条腿每 5 分钟对同一批行白发一次上游请求、直到永远。
+     * 窗口就是那个东西:结单超过这个天数的行不再进候选集。
+     */
+    private static final int SETTLE_BACKFILL_DAYS = 7;
+
     /**
      * 这个异常是不是「上游查无此单」这种**正常业务回答**(是则返回 true,调用方降成 WARN)。
      *
@@ -179,6 +189,11 @@ public class LsyReconcileScheduler {
         } catch (Exception e) {
             log.error("退款对账任务异常", e);
         }
+        try {
+            backfillRefundSettle();
+        } catch (Exception e) {
+            log.error("实退补写任务异常", e);
+        }
         try {
             closeExpiredQrOrders();
         } catch (Exception e) {
@@ -296,6 +311,66 @@ public class LsyReconcileScheduler {
         }
     }
 
+    /**
+     * 补写**实退金额**({@code refund_settle})—— 2026-09-30 新增,用户报障
+     * 「退款记录 实退(元) 无数据」。
+     *
+     * <p>【为什么单开一条腿,而不是并进 {@link #reconcilePendingRefunds} 的谓词】
+     * 那个谓词上有两条被论证过的、不可顺手改动的不变量:
+     * <ol>
+     *   <li><b>非终态</b>({@code LsyRefundStatus.PENDING})—— 终态退款的状态不可回写;
+     *       而「实退为空」天然指向一个**已终态**的候选集(见下),两件事的集合根本不同;</li>
+     *   <li><b>谓词必须非单调</b>(游标 {@code next_query_time}),否则 {@code LIMIT 100}
+     *       之外的行永远轮不到 —— 饿死是稳态而非概率事件(台账 §2.3)。</li>
+     * </ol>
+     * 单开一条腿,那条谓词与本文件里那批钉它的用例一个字都不用改。
+     *
+     * <p>【候选集是谁】已经终态、因此**永远不会再进 PENDING 谓词**、而实退还空着的成功退款:
+     * <ul>
+     *   <li>{@code LsyRefundService.createRefund} 同步成功的行(返回前就已经是 SUCCESS);</li>
+     *   <li>退款**回调**先到、对账腿还没轮到它的行。</li>
+     * </ul>
+     *
+     * <p>【出口在哪】补上实退 ⇒ 不再满足 {@code refund_settle IS NULL},离开候选集。
+     * 上游始终不回这个字段(文档里它是可空项)的行则由**窗口**兜底:只问最近
+     * {@value #SETTLE_BACKFILL_DAYS} 天内结的单,过了就不再问 —— 没有这条,
+     * 候选集只进不出,这条腿会每 5 分钟白发一次上游请求直到永远。
+     *
+     * <p>【与 PENDING 那条腿的关系】两条腿各有各的 {@code LIMIT 100},互不挤占;
+     * 本方法**不推游标**(它不读游标),因为它靠窗口而不是游标来有界。
+     *
+     * <p>【纪律】与另几条腿同款:本方法一个上游字段都不解析(解析在
+     * {@link LsyRefundService#backfillSettle}),单条异常不中断其余、且绝不外抛。
+     */
+    void backfillRefundSettle() {
+        OffsetDateTime cutoff = OffsetDateTime.now().minusDays(SETTLE_BACKFILL_DAYS);
+        List<LsyRefundEntity> refunds = refundMapper.selectList(
+                new LambdaQueryWrapper<LsyRefundEntity>()
+                        // 只有退款**成功**的行才谈得上实退。在途行由 PENDING 那条腿照顾;
+                        // 失败行的 refund_settle 本来就不该有值,扫进来等于每轮白发一次请求。
+                        .eq(LsyRefundEntity::getStatus, LsyRefundStatus.SUCCESS.name())
+                        .isNull(LsyRefundEntity::getRefundSettle)
+                        // 用 finish_time(结单时间)而不是 created_time(发起时间):
+                        // 这条腿问的是「这笔退款结掉的时候实退多少」,窗口该按结单时刻划。
+                        .ge(LsyRefundEntity::getFinishTime, cutoff)
+                        .orderByAsc(LsyRefundEntity::getFinishTime)
+                        .last("LIMIT 100"));
+
+        for (LsyRefundEntity refund : refunds) {
+            try {
+                refundService.backfillSettle(refund);
+            } catch (Exception e) {
+                // 与另两条腿同一套分流:上游「这笔我这儿没有」是设计内状态,降成不带栈的 WARN。
+                if (upstreamSaysNotFound(e)) {
+                    log.warn("实退补写:上游暂无此退款,留待下轮: outRefundNo={}, upstream={}",
+                            refund.getOutRefundNo(), e.getMessage());
+                } else {
+                    log.error("实退补写异常: outRefundNo={}", refund.getOutRefundNo(), e);
+                }
+            }
+        }
+    }
+
     /**
      * 把退款行的对账游标推到下一个周期。**这是消除饿死的机制本体。**
      *

+ 16 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyQueryDetailAssembler.java

@@ -29,11 +29,18 @@ import java.util.Map;
  *       `V1.8` 里已无此说法。(这是全仓第 9 处 —— 台账 §一记的是 8 处,它只统计**假话**,
  *       这类**引用假话**的交叉引用没进统计。)</li>
  * </ul>
- * 明确**不**放的(列注释写着「本侧不读」或属上游身份):
+ * 明确**不**放的(属上游身份,或者我们还没把它写进对外契约):
  * `qd_trade_no`、`td_trade_no`、`qd_refund_no`、`refund_settle`、`user_id`、
  * `mch_no`、`shop_no`、`inst_no`、`pay_trace_no`、`refund_trace_no`,以及
  * `return_code` / `result_code` / `return_msg` 三个上游协议字段。
  *
+ * <p>⚠ 2026-09-30 订正上面这行的括注:它原来写的是「列注释写着「本侧不读」或属上游身份」,
+ * 其中「本侧不读」对 `refund_settle` **已经不成立** —— 我们现在读它、落它、并在管理端
+ * 「退款记录」页展示成「实退(元)」(见 `LsyRefundService#persistRefundSettle` 与
+ * `LsyReconcileScheduler#backfillRefundSettle`)。它仍不进本白名单,理由换成一个仍成立的理由:
+ * **它没写进对外契约**(`collection-openapi.md` 的 `detail` 一节里没有这一格)。
+ * 放它出去是一次对外契约变更,不顺手做。
+ *
  * <p>【金额为什么转 Long】上游回来的是字符串(`"100"`)。其它接口的金额都是 JSON 数字,
  * 这里要是原样透字符串,租户得为同一个概念写两套解析。转不出来就返回 null ——
  * 不把没法解释的值编一个数字送出去。金额的权威口径始终是订单/退款单本身,`detail` 是核对用的旁证。
@@ -76,12 +83,19 @@ public class LsyQueryDetailAssembler {
      * <p>`refund_no` 放在这里而不是响应顶层,是因为它是**上游给的**值;
      * 我们自己的退款单号 `out_refund_no` 在发起退款时就已经给过租户,
      * 且可由 `LsyRefundEntity` 直接读,不需要从上游报文里取。
+     *
+     * <p>⚠ 2026-09-30 订正一处**取错字段**:对外叫 `refund_fee` 的这一格,取值来源
+     * 从上游的 `refund_fee` 改成了 `refund_amount`。理由不是口味,是**原来那个名字这条腿从不下发**:
+     * 实证 `/v3/queryrefund` 的真实键集({@code logs/_lsy-refund-query-probe.txt},真打上游)里
+     * 有 `refund_amount` 与 `refund_settle`,**没有** `refund_fee` —— `refund_fee` 是**退款接口**
+     * (`/v3/refundorder`)的词。于是这一格恒为 `null`,而对外契约(`collection-openapi.md`)
+     * 一直写着它会给「退款金额(分)」。对外键名**不变**(改名是另一次契约变更),只换取值来源。
      */
     public Map<String, Object> forRefund(Map<String, Object> upstream) {
         Map<String, Object> detail = new LinkedHashMap<>();
         detail.put("refund_result", resultOf(upstream));
         detail.put("refund_no", toStr(get(upstream, "refund_no")));
-        detail.put("refund_fee", toLong(get(upstream, "refund_fee")));
+        detail.put("refund_fee", toLong(get(upstream, "refund_amount")));
         return detail;
     }
 

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

@@ -254,6 +254,11 @@ public class LsyRefundService {
         //   refund_no / qd_refund_no / refund_settle 与状态无关,不能塞进那个 patch。
         //   ★ 必须在这一刻落库:它们只出现在这里 —— 退款回调报文不带这三个字段(台账 R41),
         //   丢在这里就再也补不回来。其中 refund_no 是对外那个「退款单号」。
+        //   ⚠ 2026-09-30 订正上面那句的一个例外:**refund_settle 不适用「只出现在这里」**。
+        //   退款接口的响应字段表(showdoc 196236527)里没有它 —— 这一行读到的恒是 null,
+        //   所以它**从来没有落过库**。它的真实来源是退款查询(见 persistRefundSettle 的
+        //   javadoc)。这一行保留不删:它是**零成本的前向兼容**(上游哪天补上就自动生效),
+        //   删掉反而会在某个我们没量过的通道上丢数据。
         entity.setRefundNo(str(resp.get("refund_no")));
         entity.setQdRefundNo(str(resp.get("qd_refund_no")));
         entity.setRefundSettle(parseLong(resp.get("refund_settle")));
@@ -433,6 +438,12 @@ public class LsyRefundService {
      */
     public String settleFromQuery(LsyRefundEntity refund, Map<String, Object> resp) {
         if (parser.isPaySuccess(resp)) {
+            // ★ 实退金额**只能在这里落库**(2026-09-30 修,用户报障「退款记录 实退(元) 无数据」)。
+            //   放在 applyNotifyResult **之前**:它是上游的事实,与「谁写的那次状态流转」无关
+            //   —— 回调抢先写了 SUCCESS 时下面会 return null,但那不构成丢掉实退的理由。
+            //   只认 PAY_SUCCESS:在途时上游就算回了这个字段也可能是暂定值,而实退是**已结**的事实;
+            //   放到两支在途/未知码分支里去写还会破坏它们「一次库都不写」的既有不变量。
+            persistRefundSettle(refund, resp);
             if (!applyNotifyResult(refund, str(resp.get("result_code")),
                     str(resp.get("return_msg")), "SUCCESS")) {
                 // ★ 与 createRefund 同步成功分支同一条理由:命中 0 行只可能是
@@ -503,6 +514,65 @@ public class LsyRefundService {
         refundMapper.update(patch, w);
     }
 
+    /**
+     * 把**实退金额**(上游 {@code refund_settle})补写到退款行上(2026-09-30 新增)。
+     *
+     * <p>【为什么需要它】用户 2026-09-30 报障「退款记录 实退(元) 无数据」。根因不是脏数据,
+     * 是**读错了腿**:此前唯一一次读 {@code refund_settle} 发生在 {@link #createRefund},
+     * 读的是**退款接口** {@code /v3/refundorder} 的响应 —— 而该接口的响应字段表
+     * (showdoc 196236527)**根本没有这个字段**,取到的恒是 null,再被 MyBatis-Plus 的
+     * NOT_NULL 策略静默跳过 ⇒ 这一列自建表起**从来没有过值**,全程没有一个字节报错。
+     * 会回这个字段的是**退款查询** {@code /v3/queryrefund}。
+     *
+     * <p>【依据是实测不是文档推断】{@code logs/_lsy-refund-query-probe.txt}(2026-09-28 真打上游)
+     * 记着 {@code /v3/queryrefund} 的真实键集:**含** {@code refund_settle},**不含**
+     * {@code refund_fee}。(后者是**退款接口**的词,被 {@code LsyQueryDetailAssembler}
+     * 误当成了这条腿的字段名 —— 同一次排查里一并订正。)
+     *
+     * <p>【为什么单独一次 UPDATE】与 {@link #persistUpstreamIdentity} 同一条理由:
+     * 「退款行的状态只有一个写入口」是本模块的硬约束,非状态字段不并进状态补丁。
+     *
+     * <p>【缺席时不写】{@code refund_settle} 在文档里是可空项,「上游这次没给」是**常态**。
+     * 这时什么都不做 —— 既不编一个数字(把「不知道」写成「就是这么多」,对账时看不出来),
+     * 也不把已有的值擦掉。
+     */
+    private void persistRefundSettle(LsyRefundEntity refund, Map<String, Object> resp) {
+        Long settle = parseLong(resp == null ? null : resp.get("refund_settle"));
+        if (settle == null) {
+            return;
+        }
+        LsyRefundEntity patch = new LsyRefundEntity();
+        patch.setRefundSettle(settle);
+        patch.setUpdatedTime(OffsetDateTime.now());
+
+        UpdateWrapper<LsyRefundEntity> w = new UpdateWrapper<>();
+        w.eq("id", refund.getId());
+        refundMapper.update(patch, w);
+    }
+
+    /**
+     * 补写**实退金额**:问一次退款查询,只写 {@code refund_settle},**一个字都不碰状态**。
+     *
+     * <p>【为什么要有这条独立通道】{@link #settleFromQuery} 那条路只照顾得到「还停在
+     * {@code PENDING}」的行({@code LsyReconcileScheduler.reconcilePendingRefunds} 的扫描谓词),
+     * 而实退为空的行里有两大类**已经终态**、永远不会再进那个谓词的:
+     * <ol>
+     *   <li>{@link #createRefund} 同步成功的行 —— 它在本方法返回前就已经是 {@code SUCCESS};</li>
+     *   <li>退款**回调**先到、对账腿还没轮到它的行 —— 回调把它推成终态,对账腿随即失去候选资格。</li>
+     * </ol>
+     * 用户报障时看的那一页,正是这两类。
+     *
+     * <p>【为什么不复用 settleFromQuery】终态行的状态是**不可回写**的
+     * ({@code LsyRefundStatus.PAY_WRITE_FROM} 只含 CREATED/REFUNDING),复用等于把
+     * 「迟到的成功」那条已裁定过的路径重新打开。本方法只调 {@link #persistRefundSettle},
+     * 结构上不可能写状态。
+     *
+     * <p>异常(含上游「查无此退款」)由调用方兜 —— 这是一条**补写**,不值得让一轮对账失败。
+     */
+    public void backfillSettle(LsyRefundEntity refund) {
+        persistRefundSettle(refund, queryRefund(refund));
+    }
+
     /**
      * 退款结果回写 —— **退款行的唯一状态写入口**,条件更新,受影响行数 = 1 才算真的流转。
      *

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

@@ -270,7 +270,13 @@ COMMENT ON COLUMN pay_lsy_refund.refund_trace_no      IS '退款流水号(本
 COMMENT ON COLUMN pay_lsy_refund.refund_no            IS '上游退款单号(同步响应回传,可空)';
 COMMENT ON COLUMN pay_lsy_refund.qd_refund_no         IS '上游返回,原样落库(本侧不读)';
 COMMENT ON COLUMN pay_lsy_refund.refund_fee           IS '退款金额(分)';
-COMMENT ON COLUMN pay_lsy_refund.refund_settle        IS '上游返回,原样落库(本侧不读)';
+-- ⚠ 2026-09-30 改文案:原文是「上游返回,原样落库(**本侧不读**)」,那句**现在是假的** ——
+--   我们读它、落它,并在管理端「退款记录」页展示成「实退(元)」(LsyRefundService#persistRefundSettle
+--   + LsyReconcileScheduler#backfillRefundSettle)。改文案的另一半原因:本仓的
+--   LsyQueryDetailAssembler 曾**引用这句列注释**当作「不放进对外白名单」的依据,
+--   留着一句假注释就等于给下一个读它的人一个错的前提。
+--   同 refund_no 那条:这是 COMMENT ON(**不是** -- 注释)⇒ 已建好的库要重发一次才同步。
+COMMENT ON COLUMN pay_lsy_refund.refund_settle        IS '实退金额(分);上游退款查询回传,退款成功后才会有值';
 COMMENT ON COLUMN pay_lsy_refund.status               IS '退款状态: CREATED/REFUNDING/SUCCESS/FAIL';
 COMMENT ON COLUMN pay_lsy_refund.next_query_time      IS '下次对账时间(退避调度)';
 COMMENT ON COLUMN pay_lsy_refund.upstream_result_code IS '上游结果码';

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

@@ -611,6 +611,106 @@ class LsySchedulerTest {
         verify(refundService).settleFromQuery(eq(r), any());
     }
 
+    // ============ 实退金额(refund_settle)补写腿 —— 2026-09-30 ============
+    //
+    // 【为什么单开一条腿,而不是加进 reconcilePendingRefunds 的谓词】
+    //   那条腿的扫描谓词里有两条**被论证过**的不变量:
+    //     · 只扫非终态(`LsyRefundStatus.PENDING`)—— 终态行的状态不可回写;
+    //     · 谓词必须**非单调**(游标),否则 `LIMIT 100` 之外的行永远轮不到(饿死稳态)。
+    //   「实退为空」天然带一个**已终态**的候选集(同步成功、回调先到那两类行),
+    //   塞进同一个谓词就要同时动这两条;单开一条腿,那两条一个字都不用改。
+    //
+    // 【用户报障】2026-09-30「退款记录 实退(元) 无数据」。根因:实退只在退款接口
+    //   `/v3/refundorder` 的响应里读过,而该接口**不返回**这个字段(实证见
+    //   `logs/_lsy-refund-query-probe.txt`:`/v3/queryrefund` 的真实键集里有 `refund_settle`)。
+    //   补写通道补的就是那两类**永远不会再进 PENDING 谓词**的行。
+
+    private LsyRefundEntity settledRefund() {
+        LsyRefundEntity r = new LsyRefundEntity();
+        r.setId(31L);
+        r.setOrderId(1L);
+        r.setOutRefundNo("RF031");
+        r.setRefundNo("R031");
+        r.setRefundFee(30L);
+        r.setStatus("SUCCESS");
+        r.setFinishTime(OffsetDateTime.now().minusMinutes(1));
+        return r;
+    }
+
+    /** 扫描条件:**已退款成功** ∩ **实退为空** ∩ 在补写窗口内;按结单时间升序、限量 100。 */
+    @Test
+    void backfillRefundSettle_扫描条件为成功且实退为空且在窗口内() {
+        when(refundMapper.selectList(any())).thenReturn(List.of());
+
+        scheduler().backfillRefundSettle();
+
+        var captor = org.mockito.ArgumentCaptor.forClass(AbstractWrapper.class);
+        verify(refundMapper).selectList(captor.capture());
+        AbstractWrapper<?, ?, ?> w = captor.getValue();
+        String where = w.getSqlSegment();
+
+        assertEquals("SUCCESS", equalityStatusGuardValue(w),
+                "只有**退款成功**的行才谈得上实退 —— 在途/失败行的 refund_settle 本来就不该有值,"
+                        + "把它们扫进来等于每轮白发一次上游请求。实测: " + where);
+        assertTrue(where.contains("refund_settle IS NULL"),
+                "只补**还空着**的行 —— 已经补上的行必须离开候选集,否则这条腿没有出口。实测: " + where);
+        assertTrue(where.contains("finish_time >="),
+                "必须有窗口:上游始终不回 refund_settle 的行否则会被无限重问,"
+                        + "候选集只进不出。实测: " + where);
+        assertFalse(where.contains("created_time"),
+                "窗口用 finish_time(结单时间)而不是 created_time —— 后者的含义是「什么时候发起的」,"
+                        + "与「什么时候结的」不是一回事,而这条腿问的是后者。实测: " + where);
+        assertTrue(where.contains("ORDER BY finish_time ASC"),
+                "等得最久的先补。实测: " + where);
+        assertTrue(where.contains("LIMIT 100"),
+                "一轮的代价必须封顶 —— 部署后第一轮会撞上全部历史空白行。实测: " + where);
+    }
+
+    /**
+     * 正控:命中的行必须交给 refundService 补写。
+     *
+     * <p>【为什么正控不可省】同 {@link #reconcilePendingRefunds_命中时交给refundService解释}:
+     * Mockito 对未 stub 的 `selectList` 默认返回空列表,没有这一条,
+     * 一个「永远扫不到任何行」的坏实现能让上面那条谓词断言与下面所有负路径用例全绿。
+     */
+    @Test
+    void backfillRefundSettle_命中时交给refundService补写() {
+        LsyRefundEntity r = settledRefund();
+        when(refundMapper.selectList(any())).thenReturn(List.of(r));
+
+        scheduler().backfillRefundSettle();
+
+        verify(refundService).backfillSettle(r);
+    }
+
+    @Test
+    void backfillRefundSettle_单条异常不中断其余且不外抛() {
+        LsyRefundEntity r1 = settledRefund();
+        LsyRefundEntity r2 = settledRefund();
+        r2.setId(32L);
+        when(refundMapper.selectList(any())).thenReturn(List.of(r1, r2));
+        doThrow(new RuntimeException("网络超时")).when(refundService).backfillSettle(r1);
+
+        assertDoesNotThrow(() -> scheduler().backfillRefundSettle());
+
+        verify(refundService).backfillSettle(r2);
+    }
+
+    /**
+     * `reconcile()` 必须**真的接上**这条腿 —— 只写方法不接入是「加了但没用」的典型,
+     * 本模块已经吃过一次同形的亏(`notifyRefund` 存在但无调用者)。
+     */
+    @Test
+    void reconcile_已接入实退补写腿() {
+        when(orderMapper.selectList(any())).thenReturn(List.of());
+        LsyRefundEntity r = settledRefund();
+        when(refundMapper.selectList(any())).thenReturn(List.of(r));
+
+        scheduler().reconcile();
+
+        verify(refundService).backfillSettle(r);
+    }
+
     /**
      * 取 {@code status = #{...}} **单值等值**守卫绑定的那个值;WHERE 里没有这个谓词时返回 {@code null}。
      *

+ 8 - 2
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyQueryDetailAssemblerTest.java

@@ -143,12 +143,18 @@ class LsyQueryDetailAssemblerTest {
         upstream.put("refund_no", "REF001");
         upstream.put("qd_refund_no", "QDREF001");
         upstream.put("refund_trace_no", "trace-refund");
-        upstream.put("refund_fee", "100");
-        upstream.put("refund_settle", "100");
+        // ★ 夹具按**退款查询**的真实键集写(实证:logs/_lsy-refund-query-probe.txt,
+        //   2026-09-28 真打上游):里面有 `refund_amount` / `refund_settle`,
+        //   **没有** `refund_fee`。旧夹具塞的是 `refund_fee` —— 那是**退款接口**
+        //   的词,这条腿从不下发,于是「映射恒为 null」被一个假报文形状盖住了。
+        upstream.put("refund_amount", "100");
+        upstream.put("refund_settle", "97");
         upstream.put("pay_trace_no", "trace-abc");
 
         Map<String, Object> detail = assembler.forRefund(upstream);
         assertEquals(Set.of("refund_result", "refund_no", "refund_fee"), detail.keySet());
+        assertEquals(100L, detail.get("refund_fee"),
+                "detail.refund_fee 必须取自这条腿真实下发的 refund_amount(申请退款金额)");
     }
 
     @Test

+ 111 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java

@@ -706,6 +706,117 @@ class LsyRefundServiceTest {
         verify(callbackService, never()).notifyOrder(any());
     }
 
+    // ---- 实退金额(refund_settle):唯一来源是**退款查询**那条腿 ----
+    //
+    // 【用户报障 2026-09-30】「退款记录 实退(元) 无数据」。根因不是脏数据,是**读错了腿**:
+    //   · `createRefund` 从**退款接口** `/v3/refundorder` 的响应里取 `refund_settle`(:259),
+    //     而该接口的响应字段表(showdoc 196236527)**没有这个字段** ⇒ 取到的恒是 null,
+    //     再被 MP 的 NOT_NULL 策略静默跳过 ⇒ 这一列自建表起**从来没有过值**。
+    //   · 真正会回它的那条腿 `/v3/queryrefund`(退款查询),在 `settleFromQuery` 里
+    //     **只写状态**,把它丢掉了。
+    // 【实证,不是推断】`logs/_lsy-refund-query-probe.txt`(2026-09-28 真打上游)记着
+    //   `/v3/queryrefund` 的真实键集,里面**有** `refund_settle`、**没有** `refund_fee`。
+    // 【为什么上面那条 `refund_上游响应里的字段_补写到预插入的那一行` 一直是绿的】
+    //   因为本类的 `resp()` 夹具**自己往退款接口响应里塞了** `refund_settle`(见该辅助方法)
+    //   —— 上游从不这么发。夹具造出了一个现实中不存在的报文形状。
+
+    /**
+     * 正控:退款查询确认成功且回传 `refund_settle` ⇒ 必须落库。
+     *
+     * <p>没有这条,「实退列永远为空」这个缺陷在单测里**看不见**。
+     */
+    @Test
+    void settleFromQuery_退款查询带refund_settle_落库() {
+        LsyRefundEntity refund = refund("REFUNDING");
+        refund.setOrderId(1L);
+        LsyOrderEntity o = order();
+        when(refundMapper.update(any(), any())).thenReturn(1);
+        when(orderMapper.selectById(1L)).thenReturn(o);
+        when(orderService.applyRefundResult(eq(o), eq(30L), anyString())).thenReturn(true);
+
+        Map<String, Object> r = resp("PAY_SUCCESS");
+        r.put("refund_settle", "97");
+        service.settleFromQuery(refund, r);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        verify(refundMapper, atLeastOnce()).update(captor.capture(), any());
+        assertTrue(captor.getAllValues().stream()
+                        .anyMatch(x -> Long.valueOf(97L).equals(x.getRefundSettle())),
+                "退款查询回传的实退金额必须落库 —— 它是 refund_settle 的唯一来源");
+    }
+
+    /**
+     * 上游没回 `refund_settle` 时**不许编一个数字**。
+     *
+     * <p>该字段在文档里是可空项(必填列留空),所以「缺席」是常态而不是异常。
+     * 编一个等于把「不知道」写成「就是这么多」—— 对账时看不出来。
+     */
+    @Test
+    void settleFromQuery_退款查询没带refund_settle_不写这一列() {
+        LsyRefundEntity refund = refund("REFUNDING");
+        refund.setOrderId(1L);
+        LsyOrderEntity o = order();
+        when(refundMapper.update(any(), any())).thenReturn(1);
+        when(orderMapper.selectById(1L)).thenReturn(o);
+        when(orderService.applyRefundResult(eq(o), eq(30L), anyString())).thenReturn(true);
+
+        Map<String, Object> r = resp("PAY_SUCCESS");
+        r.remove("refund_settle");
+        service.settleFromQuery(refund, r);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        // 正控:这一轮**确实写了库**(状态那一次),否则下面的 allMatch 是空集上恒真。
+        verify(refundMapper, atLeastOnce()).update(captor.capture(), any());
+        assertTrue(captor.getAllValues().stream().allMatch(x -> x.getRefundSettle() == null),
+                "响应里没有这个字段时不得编一个数字 —— 只能留空等下一轮");
+    }
+
+    /**
+     * 在途 / 未知码两支**仍然一次库都不写**(含 `refund_settle`)。
+     *
+     * <p>这两支的「不写库」是既有不变量,由上面 `settleFromQuery_退款申请中_不落状态返回null`
+     * 与 `settleFromQuery_未知码_不落任何状态` 的 `never()` 钉着。本条是**它们的加固版**:
+     * 那两条用的夹具里 `refund_settle` 是**有值**的(`resp()` 塞了 "100"),
+     * 所以只要有人把实退写入挪到分支分发之前,它们就会红 —— 这正是想要的报警。
+     * 这里把它写成显式断言,是为了让「为什么它们能挡住这次改动」不必靠读者自己发现。
+     */
+    @Test
+    void settleFromQuery_在途与未知码_连实退也不写() {
+        LsyRefundEntity a = refund("REFUNDING");
+        LsyRefundEntity b = refund("REFUNDING");
+
+        assertNull(service.settleFromQuery(a, resp("PAY_IN_PROCESS")));
+        assertNull(service.settleFromQuery(b, resp("SOME_UNKNOWN_CODE")));
+
+        verify(refundMapper, never()).update(any(), any());
+    }
+
+    /**
+     * 终态行补写实退:**只写实退,不碰状态**。
+     *
+     * <p>这是同步成功 / 回调先到那两类行的唯一补写通道 —— 它们的状态行已终态,
+     * 对账腿的 PENDING 谓词扫不到它们(见 `LsyReconcileScheduler#backfillRefundSettle`)。
+     */
+    @Test
+    void backfillSettle_只写实退不碰状态() {
+        LsyRefundEntity refund = refund("SUCCESS");
+        refund.setOrderId(1L);
+        refund.setRefundNo("R001");
+        when(client.post(eq("/v3/queryrefund"), anyMap()))
+                .thenReturn(Map.of("return_code", "SUCCESS", "result_code", "PAY_SUCCESS",
+                        "refund_settle", "97"));
+        when(orderMapper.selectById(1L)).thenReturn(order());
+        when(refundMapper.update(any(), any())).thenReturn(1);
+
+        service.backfillSettle(refund);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        verify(refundMapper).update(captor.capture(), any());
+        LsyRefundEntity patch = captor.getValue();
+        assertEquals(97L, patch.getRefundSettle(), "必须落上游回传的实退金额");
+        assertNull(patch.getStatus(), "补写实退不得顺手写状态 —— 终态行的状态不可回写");
+    }
+
     // ---- applyNotifyResult:退款行的幂等闸门(Task 12 依赖) ----
 
     @Test