فهرست منبع

fix(乐收赢): 聚合码到期关单被拒「链接不存在或已过期」时本地收敛为 EXPIRED

`/v3/qrclose` 对**已过期**的聚合码回 `return_code=FAIL` +「该聚合码链接不存在或已过期!」,
`parseV3` 因此抛异常,被 `closeExpiredQrOrders` 记成带栈 ERROR 之后**什么都不做** ⇒
订单留在 PAYING、每 5 分钟重试一次,直到 absoluteExpireHours(24 小时)才被绝对过期收走。
而对外契约(collection-openapi.md 的 FAQ「用户一直不付款,二维码会怎样?」)写的是
「有效期默认 15 分钟(可用 time_expire 指定)。超时后订单状态变为 EXPIRED」——
实现与契约相反:那个 EXPIRED 要等 24 小时。

判据是**复发的读数**,不是形状。这段代码原先的注释写着「我没有它复发的读数,
所以维持 ERROR + 栈不变」;现在有了,三次同向:

- 2026-09-30(logs/payment-platform.log):同一笔 QR1790675073,19 次 qrclose 请求 /
  19 条这条拒绝 / **0 次成功**;
- 2026-09-22(java/logs/payment-platform-error.log):3 笔单共 71 次拒绝,同样 0 次成功;
- 历史上**唯一成功过**的 qrclose 是探针拿**刚建出来**的码关的
  (logs/_lsy-close-control.txt 对照二,建单后约 1 秒即关)。

90 次实测里本腿一次都没成功过 ⇒「上游不认这个 code_url 属反常」这个前提被复发率推翻:
它就是这个腿的正常遭遇。

改动:

- 新增 `upstreamSaysQrLinkGone(Throwable)`,判据=上游原文片段,与 `upstreamSaysNotFound`
  同形(读 `getRawReturnMsg()`,日志用 `getMessage()`)。命中 ⇒ 不带栈的 WARN +
  `markExpired(order)` 收敛为 EXPIRED;先记 WARN 再动状态,收敛那步若抛异常原文也已在案。
- 收敛**不是 CLOSED**:上游回的是**拒绝**、它没关掉这张码,而 CLOSED 还在
  `REFUND_WRITE_FROM` 里(退款可写)。EXPIRED 才是这里的事实,也同族于
  `closeQrOrder` / `closeH5Order` 那两处「句柄不可用」返回 EXPIRED 的分支。
- 其余异常一个字都不改:仍是 ERROR + 栈 + 状态不动。措辞变了就退回吵闹的一侧 ——
  一笔**还能被支付**的单不会被本地判死。
- `LsyCancelService` 一字未动:手工关单按钮作用于**还可能有效**的码,那条路必须继续
  如实报「关单未成功」;收敛只发生在调度腿(谓词保证扫到的每一笔都已过本地有效期)。
- javadoc 订正三处会因此变假的句子:类头 ②③ 两条(② 的「唯一本地例外」现在是两个,
  ③ 那句「不看任何上游输入」的主语是它的**触发**、不是 markExpired 这个方法体)、
  `LsyOrderService` 里 ④「closeExpiredQrOrders 首次真正生效」那句(实测它从未成功过)。
- 残余风险写进 `upstreamSaysQrLinkGone` 的 javadoc 而不是省略:EXPIRED 不在
  `PAY_WRITE_FROM` 里 ⇒ 写错不可逆。能误判的窗口是「付款恰好落在同一轮 ① 与 ④ 之间」,
  比 H5 分支(连上游都不问)与 24 小时绝对阈值都强;取这个方向是因为契约已经在卖。

测试(TDD,先红后绿;本类此前对这条腿**零覆盖**):

- `closeExpiredQrOrders_上游说链接已不存在或已过期_本地收敛为EXPIRED`
- `closeExpiredQrOrders_其他上游失败_仍是带栈ERROR且不动状态`(反向守卫)

变异(两向都做了,读数各自可分辨):
- 收敛改成无条件(`if (true)`)⇒ 反向守卫红、正向绿;
- 常量改成 `NOT_FOUND_MSG` 的值(真实的复制粘贴缺陷)⇒ 正向红、反向绿;
- 无害变异(局部变量改名)⇒ 仍 960 绿;class 文件 17147 → 17208 B、mtime
  12:42:49 → 12:43:27,证明变异体真被重新编译;生产文件按 sha256 复核回到基线
  `bb5a776b…`(42480 B,CRLF 工作区形态)。

全量 960 绿(两把独立的尺互相印证:控制台汇总行 + 清空目录后 XML 求和,72 个类)。
基线 958 ⇒ 本条净增 2 条用例。

★ 要重新部署才生效。
alphaH 2 هفته پیش
والد
کامیت
00fb1c27d1

+ 117 - 12
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java

@@ -39,10 +39,16 @@ import java.util.Map;
  *   ② 关单({@link #closeExpiredQrOrders} → {@link #applyCloseResult})走 closeOrder 的
  *      QRCODE 分支:值来自**上游 `/v3/qrclose` 应答**的解析结果({@code isPaySuccess(resp)}
  *      → "CLOSED")—— 它和 ① 的区别是**入口不同(关 vs 查)**,**不是**「本地 vs 上游」;
- *      该分支唯一的本地例外是 `code_url` 非 null 但为空白串时的兜底常量 "EXPIRED"
- *      (扫描条件只保证非 null、不保证非空);
+ *      该分支有**两个**本地例外,写的都是常量 "EXPIRED":
+ *      (a) `code_url` 非 null 但为空白串时的兜底(扫描条件只保证非 null、不保证非空);
+ *      (b) 2026-09-30 加的:上游**拒绝**关单并回「该聚合码链接不存在或已过期!」时收敛
+ *      ({@link #upstreamSaysQrLinkGone})。⚠ (b) 是本类里**唯一**一个「触发条件是上游原文、
+ *      写下去的值却是本地常量」的分支 —— 它按**触发**像 ①、按**取值**像 ③,
+ *      这正是为什么这三条必须分开写、不许合并成一句;
  *   ③ 过期收敛({@link #expireStaleOrders} → {@link #markExpired}):写的是本地时间阈值
  *      判定出的常量 "EXPIRED",**不看任何上游输入**。
+ *      ⚠ 这句话的主语是本条 ③ 的**触发**,不是那个写入口的方法体:{@link #markExpired}
+ *      在 2026-09-30 之后有**两个**调用方(本条,以及 ②(b) 那个触发含上游原文的分支)。
  * 第 ④ 条下游回调重试只动推送状态(`notify_status`),不写支付状态。
  * (回调侧还有两条依据上游输入写支付状态的路径,都不在本调度器内:
  *   {@code LsyNotifyService.handlePayNotify} → {@code LsyOrderService.applyPayResult}
@@ -129,6 +135,15 @@ public class LsyReconcileScheduler {
      */
     private static final String NOT_FOUND_MSG = "订单信息不存在";
 
+    /**
+     * 「上游说这个聚合码链接已经没了」的原文片段({@link #closeExpiredQrOrders} 用)。
+     *
+     * <p>实测来源:拿一个上游不认的 `code_url` 打 `/v3/qrclose`,它回的就是这句
+     * (原始字节 `logs/_lsy-lc4/qrclose-bogus-url.bin`)。判据同
+     * {@link #upstreamSaysNotFound} —— 只比原文,不比异常类型。
+     */
+    private static final String QR_LINK_GONE_MSG = "该聚合码链接不存在或已过期";
+
     /**
      * 实退补写窗口:只看最近这么多天内**结单**的退款(见 {@link #backfillRefundSettle})。
      *
@@ -177,6 +192,72 @@ public class LsyReconcileScheduler {
                 && api.getRawReturnMsg().contains(NOT_FOUND_MSG);
     }
 
+    /**
+     * 这个异常是不是「上游说这个聚合码链接已经没了」
+     * (是则返回 true,调用方**本地收敛为 EXPIRED**)。
+     *
+     * <p>【为什么它不是 {@link #upstreamSaysNotFound} 那种「只降级、不动状态」的处理】
+     * 本腿的扫描条件是「已过 `code_url_expire_time` + 仍 PAYING + `code_url` 非空」
+     * (见 {@link #closeExpiredQrOrders} 的谓词)⇒ 扫到的**每一笔都已经过了本地有效期**,
+     * 这张码**不可能再被支付**。而原实现把这条拒绝记成带栈 ERROR 之后什么都不做:
+     * 订单留在 PAYING、每 5 分钟重试一次,直到 {@link #expireStaleOrders} 的
+     * `absoluteExpireHours`(24 小时)才收走 —— 对外契约
+     * (`collection-openapi.md` 的 FAQ「用户一直不付款,二维码会怎样?」)写的是
+     * 「有效期默认 15 分钟(可用 time_expire 指定)。超时后订单状态变为 EXPIRED」,
+     * 实现与契约相反:那个 EXPIRED 要等 24 小时。
+     *
+     * <p>【判据是复发的读数,不是形状】本分支的原注释写着「我没有它复发的读数,所以维持
+     * ERROR + 栈不变」。现在有了,且三次读数同向:
+     * <ul>
+     *   <li>2026-09-30 那次运行(`logs/payment-platform.log`):同一笔 `QR1790675073`,
+     *       19 次 `/v3/qrclose` 请求 / 19 条这条拒绝 / **0 次成功**;</li>
+     *   <li>2026-09-22 那份(`java/logs/payment-platform-error.log`):3 笔单共 71 次拒绝,
+     *       同样 **0 次成功**;</li>
+     *   <li>历史上**唯一成功过**的 qrclose 是探针拿**刚建出来**的码关的
+     *       (`logs/_lsy-close-control.txt` 对照二,建单后约 1 秒即关)。</li>
+     * </ul>
+     * 90 次实测里本腿一次都没成功过 ⇒「上游不认这个 `code_url` 属反常」这个前提被复发率
+     * 推翻:它不是反常,它就是这个腿的**正常遭遇**。
+     *
+     * <p>【措辞的两个读法都指向同一结论】原文是「不存在**或**已过期」,本处分不出是哪个
+     * (拿一个假 `code_url` 打过去回的是同一句)。但在这里不必分辨:扫描谓词已经独立证明
+     * 这笔**已过本地有效期**,两个读法下这张码都不可用、这笔单都收不到钱。
+     *
+     * <p>【为什么收敛成 EXPIRED 而不是 CLOSED】上游**没有**关掉它 —— 它回的是**拒绝**。
+     * 写 CLOSED 等于替上游宣布一个它没做的动作,而 {@code CLOSED} 还在
+     * {@link LsyOrderStatus#REFUND_WRITE_FROM} 里(退款可写)。EXPIRED 才是这里的事实:
+     * 码已失效、谁也付不进来。同族的另外两处「句柄不可用」分支
+     * ({@code LsyCancelService.closeQrOrder} 的 `code_url` 缺失、
+     * {@code closeH5Order} 的 `trade_no` 缺失)返回的也都是 EXPIRED。
+     *
+     * <p>【失败方向:故意不对称,同 {@link #upstreamSaysNotFound}】只有**这一句原文**触发
+     * 收敛。「验签失败」「商户未配置」这类真故障,以及将来上游改了措辞,都退回
+     * ERROR + 栈 + **状态不动**(一笔**还能被支付**的单不会被本地判死,也不会不再重试)。
+     * 这条不对称由 `LsySchedulerTest#closeExpiredQrOrders_其他上游失败_仍是带栈ERROR且不动状态`
+     * 钉住(把收敛改成无条件 ⇒ 那条必须红)。
+     *
+     * <p>【已知残余风险 —— 写下来是为了别让它消失】EXPIRED 不在
+     * {@link LsyOrderStatus#PAY_WRITE_FROM} 里 ⇒ 这一步**一旦写错就不可逆**:
+     * 真付了款的那笔再也写不进 SUCCESS(钱在上游,状态在我们这儿是「过期」)。
+     * 能误判的窗口:用户付款**恰好落在同一轮里 ① 与 ④ 之间**(① 问上游时那笔还不可见、
+     * ④ 紧接着把码判死),且回调丢失。比这更松的窗口不存在 —— 码一过期上游就不收新扫码,
+     * 而 ① 排在 ④ 之前、每 5 分钟问一次,付款一旦可见就会先被 ① settle 成 SUCCESS
+     * (settle 后本腿的谓词 `status='PAYING'` 也不再命中它)。
+     * 这条窗口**不是本次引入的**:H5 分支({@code h5ExpireMinutes} 后直接 {@code markExpired})
+     * 连上游都不问,{@link #expireStaleOrders} 的 24 小时绝对阈值同样只看本地时钟 ——
+     * 本分支比两者都**强**,因为上游明确说了这张码不能用。取这个方向,是因为对外契约
+     * (15 分钟 → EXPIRED)已经在卖,而维持原样会让「过期」在 24 小时内都不成立。
+     *
+     * <p>【用哪个 getter】同 {@link #upstreamSaysNotFound}:判据读 {@code getRawReturnMsg()}
+     * (上游原文,未截断),写日志一律用 {@code getMessage()}(已 sanitize + 封顶)——
+     * 直接把 raw 文本写进日志是 CWE-117。
+     */
+    private static boolean upstreamSaysQrLinkGone(Throwable e) {
+        return e instanceof LeshouyingApiException api
+                && api.getRawReturnMsg() != null
+                && api.getRawReturnMsg().contains(QR_LINK_GONE_MSG);
+    }
+
     @Scheduled(fixedDelay = 5 * 60 * 1000)
     public void reconcile() {
         try {
@@ -390,7 +471,24 @@ public class LsyReconcileScheduler {
         refundMapper.update(patch, w);
     }
 
-    /** 聚合码到期 → 调 v3/qrclose 关单 */
+    /**
+     * 聚合码到期 → 调 v3/qrclose 关单;上游回「链接不存在或已过期」时**本地收敛为 EXPIRED**。
+     *
+     * <p>谓词是「已过 `code_url_expire_time` + 仍 PAYING + `code_url` 非空」——
+     * 三个条件缺一不可,且第三个是本腿的**唯一入口凭证**:`code_url` 只在 qrpay **成功**
+     * 之后才落库。这条谓词同时是下游收敛判据的一部分(见
+     * {@link #upstreamSaysQrLinkGone}:扫到的每一笔都已过本地有效期)。
+     *
+     * <p>两条出口,都**不**把订单留在原地:
+     * <ul>
+     *   <li>上游说关掉了 ⇒ {@link #applyCloseResult} 写 CLOSED(值来自上游应答);</li>
+     *   <li>上游**拒绝**(这一句原文)⇒ 这里直接 {@link #markExpired}
+     *       (值是本地上限常量的推断,不是上游的话)。</li>
+     * </ul>
+     * 「关单未成功」那种**没抛异常**的失败({@link #applyCloseResult} 里 target 为 null)
+     * **不在**本分支内:它保持原状态等下轮,仍未收敛 —— 那条路由
+     * {@link #expireStaleOrders} 的 24 小时绝对阈值兜底,本次改动没动它。
+     */
     void closeExpiredQrOrders() {
         List<LsyOrderEntity> orders = orderMapper.selectList(
                 new LambdaQueryWrapper<LsyOrderEntity>()
@@ -404,15 +502,22 @@ 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);
+                // ★ 2026-09-30:上游回「该聚合码链接不存在或已过期!」⇒ **本地收敛为 EXPIRED**。
+                //   判据、读数与失败方向全在 upstreamSaysQrLinkGone 的 javadoc 里;这里只记
+                //   两件**代码事实**:
+                //   ① 顺序是「先记 WARN、再动状态」—— 收敛那一步(含下游推送)若抛异常,
+                //      上游原文已经落在日志里了,排障不用去猜;
+                //   ② 级别从 ERROR 降到 WARN 是因为**这是可解释的业务回答**、不是故障:
+                //      同一个探针对一个上游不认的 code_url 回的就是这句
+                //      (原始字节 logs/_lsy-lc4/qrclose-bogus-url.bin)。
+                //   其余异常一个字都不改:仍是 ERROR + 栈 + 状态不动。
+                if (upstreamSaysQrLinkGone(e)) {
+                    log.warn("聚合码已失效,本地收敛为 EXPIRED: outTradeNo={}, upstream={}",
+                            order.getOutTradeNo(), e.getMessage());
+                    markExpired(order);
+                } else {
+                    log.error("聚合码关单异常: outTradeNo={}", order.getOutTradeNo(), e);
+                }
             }
         }
     }

+ 4 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java

@@ -280,7 +280,10 @@ public class LsyOrderService {
             //   且 reconcilePendingOrders / expireStaleOrders 都过滤非终态,两条对账路也捞不到。
             //   PAYING 非终态,四处既有机制就地对上:① 在 PAY_WRITE_FROM 内 → 回调可写;
             //   ② PAYING+QRCODE 命中 reconcilePendingOrders 的两个过滤条件 → 对账捞得起;
-            //   ③ PAYING ∈ PENDING → 绝对过期兜底;④ closeExpiredQrOrders 首次真正生效(qrclose 关单)。
+            //   ③ PAYING ∈ PENDING → 绝对过期兜底;④ 命中 closeExpiredQrOrders 的扫描谓词
+            //      (过期 → 调 qrclose;上游回「链接不存在或已过期」时,2026-09-30 起就地收敛为 EXPIRED)。
+            //      ⚠ 别把 ④ 读成「关单能成功」:实测 90 次**到期码**的 qrclose 请求一次都没成功过
+            //        (全被上游以「该聚合码链接不存在或已过期!」拒绝),所以 ④ 的实际效果一直是收敛,不是关单。
             //   【与 createBarcodeOrder 的区别】被扫写 SUCCESS 是对的,判据是
             //   「下单时用户有没有可能已经付完款」:被扫是用户出示码、我们当场提交,
             //   上游同步返的就是真实付款结果 → 可写终态;主扫的码还没交到用户手上 → 只能落非终态。

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

@@ -408,6 +408,100 @@ class LsySchedulerTest {
         verify(orderMapper, never()).updateById(any());
     }
 
+    // ============ 关单被拒「该聚合码链接不存在或已过期」⇒ 本地收敛(2026-09-30) ============
+    //
+    // 【为什么改】原实现把这条拒绝记成带栈 ERROR 并**什么都不做**:订单留在 PAYING、
+    // 每 5 分钟重试一次,直到 absoluteExpireHours(24 小时)才被绝对过期收走。而对外契约
+    //(collection-openapi.md 的 FAQ「用户一直不付款,二维码会怎样?」)写的是
+    // 「有效期默认 15 分钟(可用 time_expire 指定)。超时后订单状态变为 EXPIRED」——
+    // 实现与契约相反,那个 EXPIRED 要等 24 小时才来。
+    //
+    // 【判据是复发的读数,不是形状】改变结论的正是原注释自认「我没有」的那份读数:
+    //   ① 2026-09-30 那次运行(logs/payment-platform.log):同一笔 QR1790675073,
+    //      19 次 `/v3/qrclose` 请求 / 19 条这条拒绝 / **0 次成功**;
+    //   ② 2026-09-22 那份(java/logs/payment-platform-error.log):3 笔单 71 次拒绝,同样 0 次成功;
+    //   ③ 历史上**唯一成功过**的 qrclose 是探针拿**刚建出来**的码关的
+    //      (logs/_lsy-close-control.txt 对照二,`s-00006`,建单后约 1 秒即关)。
+    //   ⇒ 本腿只捞「已过期」的码,在 90 次实测里一次都没成功过。「上游不认这个 code_url
+    //     属反常」这个前提被复发率推翻 —— 它就是这个腿的正常遭遇。
+    //
+    // 【为什么不是 CLOSED】上游**没有**关掉它(它回的是拒绝)。写 CLOSED 等于替上游宣布一个
+    // 它没做的动作,而 CLOSED 还在 LsyOrderStatus.REFUND_WRITE_FROM 里。EXPIRED 才是这里的事实:
+    // 码已失效、谁也付不进来。同族的两处「句柄不可用」分支(closeQrOrder 的 code_url 缺失、
+    // closeH5Order 的 trade_no 缺失)返回的也都是 EXPIRED。
+    @Test
+    void closeExpiredQrOrders_上游说链接已不存在或已过期_本地收敛为EXPIRED() {
+        LsyOrderEntity o = paying(0);
+        o.setPayScene("QRCODE");
+        o.setCodeUrl("https://test.gdyfsk.com/yfpay/s/s-00006");
+        o.setCodeUrlExpireTime(OffsetDateTime.now().minusMinutes(1));   // 本腿的扫描条件:已过期
+        when(orderMapper.selectList(any())).thenReturn(List.of(o));
+        when(cancelService.closeOrder(o)).thenThrow(
+                new LeshouyingApiException("FAIL", "该聚合码链接不存在或已过期!", "{}"));
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        withLogCapture(LsyReconcileScheduler.class, events -> {
+            assertDoesNotThrow(() -> scheduler().closeExpiredQrOrders());
+
+            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"),
+                    "必须带得走是哪一笔,实测: " + warns.get(0).getFormattedMessage());
+            assertTrue(warns.get(0).getFormattedMessage().contains("该聚合码链接不存在或已过期"),
+                    "上游原文必须留下 —— 否则排障时看不出是哪种拒绝,实测: "
+                            + warns.get(0).getFormattedMessage());
+        });
+
+        var patch = org.mockito.ArgumentCaptor.forClass(LsyOrderEntity.class);
+        var where = org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper).update(patch.capture(), where.capture());
+        assertEquals("EXPIRED", patch.getValue().getStatus(), "写进去的必须是 EXPIRED,不是 CLOSED");
+        assertNotNull(patch.getValue().getFinishTime(), "收敛要落结单时间(同 markExpired)");
+        assertEquals(LsyOrderStatus.PENDING, allowedSourceStatuses(where.getValue()),
+                "守卫仍是「还没终态」—— 回调抢先写了 SUCCESS 时这条不得覆盖它");
+        assertEquals("EXPIRED", o.getStatus());
+        verify(callbackService).notifyOrder(o);
+        verify(orderMapper, never()).updateById(any());
+    }
+
+    /**
+     * 反向守卫:**别的**关单失败必须仍是带栈 ERROR,且一个字都不许改状态。
+     *
+     * <p>没有这一条,一个「凡关单抛异常就本地收敛」的实现能让上面那条全绿 —— 而那是把
+     * **真故障**(验签失败、商户未配置、参数被拒)当成「码已失效」:一笔**还能被支付**的单
+     * 会被本地判死,且再也不重试。失败方向与 {@link LsyReconcileScheduler} 里
+     * {@code upstreamSaysNotFound} 那条一致:判据只能是我们**测过的上游原文**,
+     * 措辞变了就退回吵闹的 ERROR + 保持原状。
+     *
+     * <p>本条在加实现**之前**就是绿的(钉的是既有行为),价值由变异证明:
+     * 把收敛改成无条件 ⇒ 本条必须红。
+     */
+    @Test
+    void closeExpiredQrOrders_其他上游失败_仍是带栈ERROR且不动状态() {
+        LsyOrderEntity o = paying(0);
+        o.setPayScene("QRCODE");
+        o.setCodeUrl("https://test.gdyfsk.com/yfpay/s/s-00006");
+        when(orderMapper.selectList(any())).thenReturn(List.of(o));
+        when(cancelService.closeOrder(o)).thenThrow(
+                new LeshouyingApiException("FAIL", "验签失败", "{}"));
+
+        withLogCapture(LsyReconcileScheduler.class, events -> {
+            assertDoesNotThrow(() -> scheduler().closeExpiredQrOrders());
+
+            assertEquals(1, errors(events).size(), "真故障必须留在 ERROR,实测: " + messages(events));
+            assertNotNull(events.stream().filter(e -> e.getLevel() == Level.ERROR)
+                            .findFirst().orElseThrow().getThrowableProxy(),
+                    "ERROR 必须带栈 —— 栈是查这条日志的人唯一能顺下去的东西");
+        });
+
+        verify(orderMapper, never()).update(any(), any());
+        assertEquals("PAYING", o.getStatus(), "没拿到「码已失效」这条判据时不得改动状态");
+        verify(callbackService, never()).notifyOrder(any());
+    }
+
     // ============ 退款对账腿(Job 2 第五条腿,2026-09-22) ============
     //
     // 【它补的是什么】设计 §6.6 写了「PAY_IN_PROCESS → REFUNDING,由 Job 2 查 v3/queryrefund
@@ -804,8 +898,12 @@ class LsySchedulerTest {
      * 就是这句话。把它记成带栈 ERROR,等于把**设计里明写的正常态**报成故障。
      *
      * <p>(同一个探针也测了 `/v3/qrclose`:回 `FAIL` +「该聚合码链接不存在或已过期!」。
-     * 但 `closeExpiredQrOrders` **没有**改成 WARN —— 扫描条件是「过期且仍 PAYING 且有 code_url」,
-     * 那种行是 qrpay 成功之后才有的,**我没有它复发的读数**,不凭形状推断。)
+     * ⚠ 2026-09-30 订正下面这句:它当时写的是「`closeExpiredQrOrders` **没有**改成 WARN,
+     * 因为**我没有它复发的读数**,不凭形状推断」—— 那份读数后来拿到了(19 + 71 = 90 次
+     * 同一条拒绝、0 次成功,见上面 `closeExpiredQrOrders_上游说链接已不存在或已过期_…`
+     * 那一节的注释),于是那条腿已经改了,并且**不止是改级别**:上游说码没了 ⇒ 本地收敛为
+     * EXPIRED。判据仍是同一句原文;两条腿的**动作**不同(订单腿动状态、退款腿不动),
+     * 因为退款侧没有安全的本地终态可落。)
      */
     @Test
     void reconcilePendingRefunds_上游说订单不存在_记WARN不带栈() {