Explorar o código

fix(乐收赢): 聚合码失效的收敛判据搬进服务 —— 关单按钮不再报「请稍后重试」

收「该聚合码链接不存在或已过期!」这条拒绝的腿有**三条**(收银台按钮 / 开放 API /
调度器第 ④ 条),而 00fb1c27 只让调度腿收敛。于是按钮那条路:点一次
「撤销/关单未成功,请稍后重试」,再点还是这句 —— 码已经不在上游了,**重试永远不可能
成功**,只能等下一轮调度(≤5 分钟)。改成按钮也直接收敛。

改动:

- 判据落在**唯一的调用点** `LsyCancelService.closeQrOrder`(而不是三个调用方各写一遍):
  catch 住 `return_code=FAIL` + 原文「该聚合码链接不存在或已过期」,**且本地也已过期**
  时返回 "EXPIRED";否则原样抛出。
- 「**且本地也已过期**」是承重条件,不是顺手加的:这句话有两个读法(不存在 **或** 已过期),
  而「一张还活着的码会不会回这句话」我们**没有任何读数**(唯一那条关于活码的读数是重复关
  一笔已 CLOSED 的单)。本地钟说还活着、上游说没了 = 两边互相矛盾,那是异常不是终局
  ⇒ 保持抛出(按钮如实报失败 + 日志留响),不拿一句上游措辞把可能还能付的单判死
  —— EXPIRED 不在 `PAY_WRITE_FROM` 里,写错不可逆。这个条件同时让上面那处**联调实测过**
  的行为一字不变(那时码刚建、本地还没过期)。
- 取值是 EXPIRED 不是 CLOSED:上游回的是**拒绝**,它没关掉这张码;而 CLOSED 还在
  `REFUND_WRITE_FROM` 里(退款可写)。
- 判据搬走后,调度器里 00fb1c27 加的那个 catch 分支**不可达**(closeOrder 不再抛那句)
  ⇒ 删除。收敛改由 `applyCloseResult` 的常规落库路径完成(PENDING 条件更新 + 通知下游),
  与另一处「句柄不可用」返回 EXPIRED 的分支同路。
- **副作用一条,写出来不省略**:收敛不再写 `finish_time`(那是 `markExpired` 写的)。
  已实测 `pay_lsy_order.finish_time` **没有读者**:模块里唯一一处 `getFinishTime()` 是
  `LsyOrderService.applyPayResult` 把刚写下的值回填进内存实体(不参与判定),
  前端全文只有**退款**表渲染 `finish_time`。写在这里是为了将来有人补这一格时知道它不是漏掉的。
- 接入文档 5.5 那行补一句:二维码已过期时返回 `EXPIRED` 也是**成功**,不必重试。

测试(TDD,先红后绿;红的那次读数:19 条里 1 条失败,失败原因正是
`LeshouyingApiException` 从 `closeQrOrder` 逃给了调用方):

- `qrcode_上游说链接不存在或已过期_本地已过期_返回EXPIRED不抛出`(正向;并钉住「只打一次上游」)
- `qrcode_同一句话但本地还没过期_仍抛出`(反向守卫)
- `qrcode_其他上游失败_仍抛出`(反向守卫)
- `closeExpiredQrOrders_服务原样抛出这一句_调度器不得就地收敛`(调度器侧反向守卫)
- 删除原正向调度器用例:它断言 `patch.finishTime` 非空,而那个断言在「取值改走
  applyCloseResult」之后不再为真(理由见上)。973 → 963 的账:基线 960,+3 服务用例,
  −1 调度器正向用例,+1 调度器反向守卫。

变异(两向都做,每个变异体跑完都按 sha256 复核回到基线 `9cbe3bb5…` / 20582 B / CRLF 工作区形态):

- A 去掉本地过期旁证 ⇒ **只**红「本地还没过期」那条;
- B 常量改指「验签失败」⇒ **红两条**(正向 + 「其他上游失败」那条反向守卫)—— 一个误指的
  常量会同时放过它不该放过的那句,这个双红正是它的签名;
- C 判据放宽成「只看本地过期」⇒ **只**红「验签失败」那条;
- D 无害(局部变量改名)⇒ 仍 963 绿;class 7362 B(基线)→ 7365 B(变异体)⇒ 变异体确实
  被重新编译过,不是拿旧 class 读的;
- E 在调度器 catch 里加回按原文收敛 ⇒ **只**红新增的那条调度器反向守卫。

963 绿(两把独立的尺互相印证:控制台汇总行 + 清空目录后 XML 求和,72 个类)。

★ 要重新部署才生效;文档那条要**重建前端 dist**(md 是构建期内联进页面的)。
alphaH hai 2 semanas
pai
achega
48b047cfd7

+ 1 - 1
frontend/src/views/module_payment/apikey/docs/collection-openapi.md

@@ -497,7 +497,7 @@ POST /payment/openapi/pay/order/close
 | 订单类型 | 行为 |
 |---|---|
 | 付款码支付 | 先查后撤。**已支付**则不会撤销,而是提示你改走退款;未支付才真正撤销 |
-| 二维码支付 | 按二维码关单 |
+| 二维码支付 | 按二维码关单;二维码**已过期**时上游往往已不认这张码,平台就把本地状态收敛为 `EXPIRED`(与 `CLOSED` 一样是**成功**响应,不必重试) |
 | H5 | **不支持主动关单**,平台仅把本地状态收敛为 `EXPIRED`。若用户之后仍完成支付,回调会把状态更正回来 |
 
 **响应**(B 型)

+ 19 - 105
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java

@@ -42,13 +42,11 @@ import java.util.Map;
  *      该分支有**两个**本地例外,写的都是常量 "EXPIRED":
  *      (a) `code_url` 非 null 但为空白串时的兜底(扫描条件只保证非 null、不保证非空);
  *      (b) 2026-09-30 加的:上游**拒绝**关单并回「该聚合码链接不存在或已过期!」时收敛
- *      ({@link #upstreamSaysQrLinkGone})。⚠ (b) 是本类里**唯一**一个「触发条件是上游原文、
- *      写下去的值却是本地常量」的分支 —— 它按**触发**像 ①、按**取值**像 ③,
- *      这正是为什么这三条必须分开写、不许合并成一句;
+ *      ({@code LsyCancelService#closeQrOrder} 里接住那条只剩 `return_code=FAIL` 的拒绝)。
+ *      ⚠ (b) 是这三条腿里**唯一**一个「触发条件是上游原文、写下去的值却是本地常量」的分支
+ *      —— 它按**触发**像 ①、按**取值**像 ③,这正是为什么这三条必须分开写、不许合并成一句;
  *   ③ 过期收敛({@link #expireStaleOrders} → {@link #markExpired}):写的是本地时间阈值
  *      判定出的常量 "EXPIRED",**不看任何上游输入**。
- *      ⚠ 这句话的主语是本条 ③ 的**触发**,不是那个写入口的方法体:{@link #markExpired}
- *      在 2026-09-30 之后有**两个**调用方(本条,以及 ②(b) 那个触发含上游原文的分支)。
  * 第 ④ 条下游回调重试只动推送状态(`notify_status`),不写支付状态。
  * (回调侧还有两条依据上游输入写支付状态的路径,都不在本调度器内:
  *   {@code LsyNotifyService.handlePayNotify} → {@code LsyOrderService.applyPayResult}
@@ -135,15 +133,6 @@ 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})。
      *
@@ -192,72 +181,6 @@ 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 {
@@ -472,22 +395,23 @@ public class LsyReconcileScheduler {
     }
 
     /**
-     * 聚合码到期 → 调 v3/qrclose 关单;上游回「链接不存在或已过期」时**本地收敛为 EXPIRED**。
+     * 聚合码到期 → 调 v3/qrclose 关单。
      *
      * <p>谓词是「已过 `code_url_expire_time` + 仍 PAYING + `code_url` 非空」——
      * 三个条件缺一不可,且第三个是本腿的**唯一入口凭证**:`code_url` 只在 qrpay **成功**
-     * 之后才落库。这条谓词同时是下游收敛判据的一部分(见
-     * {@link #upstreamSaysQrLinkGone}:扫到的每一笔都已过本地有效期)。
+     * 之后才落库。
      *
-     * <p>两条出口,都**不**把订单留在原地:
+     * <p>三条出口:
      * <ul>
      *   <li>上游说关掉了 ⇒ {@link #applyCloseResult} 写 CLOSED(值来自上游应答);</li>
-     *   <li>上游**拒绝**(这一句原文)⇒ 这里直接 {@link #markExpired}
-     *       (值是本地上限常量的推断,不是上游的话)。</li>
+     *   <li>上游**拒绝**这条码(原文「该聚合码链接不存在或已过期!」)⇒ `closeOrder`
+     *       返回 "EXPIRED",同一条落库路径写 EXPIRED。实测这是本腿的**正常遭遇**而不是反常:
+     *       90 次调用 0 次成功(读数见 {@code LsyCancelService#closeQrOrder})。
+     *       判据落在 `closeOrder` 里而不是这里,是因为**按钮腿收的是同一条拒绝** ——
+     *       放在服务里,三条调用腿(按钮/开放 API/本腿)的取值才不会各写各的;</li>
+     *   <li>上游**拒绝**但不给这句话 ⇒ target 为 null,保持原状态等下轮,仍未收敛 ——
+     *       那条路由 {@link #expireStaleOrders} 的 24 小时绝对阈值兜底。</li>
      * </ul>
-     * 「关单未成功」那种**没抛异常**的失败({@link #applyCloseResult} 里 target 为 null)
-     * **不在**本分支内:它保持原状态等下轮,仍未收敛 —— 那条路由
-     * {@link #expireStaleOrders} 的 24 小时绝对阈值兜底,本次改动没动它。
      */
     void closeExpiredQrOrders() {
         List<LsyOrderEntity> orders = orderMapper.selectList(
@@ -502,22 +426,12 @@ public class LsyReconcileScheduler {
             try {
                 applyCloseResult(order);
             } catch (Exception 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);
-                }
+                // 【「该聚合码链接不存在或已过期」为什么**不在这里**】这条拒绝不再走到本 catch:
+                //   `closeOrder` 自己把它接住并返回 "EXPIRED" 了(判据、读数与失败方向全在
+                //   {@code LsyCancelService#closeQrOrder} 的注释里)⇒ 收敛改由
+                //   {@link #applyCloseResult} 的常规落库路径完成,与另一处「句柄不可用」
+                //   返回 EXPIRED 的分支同路。本 catch 因此只剩真故障。
+                log.error("聚合码关单异常: outTradeNo={}", order.getOutTradeNo(), e);
             }
         }
     }

+ 89 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCancelService.java

@@ -1,6 +1,7 @@
 package com.payment.platform.module.payment.leshouying.service;
 
 import cn.hutool.core.util.IdUtil;
+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;
@@ -10,6 +11,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
 import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -39,6 +41,15 @@ public class LsyCancelService {
 
     /**
      * 关闭订单。
+     *
+     * <p>三种出口,调用方**三支都要接**:
+     * <ul>
+     *   <li>返回目标状态({@code CLOSED} / {@code CANCELED} / {@code EXPIRED} / {@code SUCCESS});</li>
+     *   <li>返回 null —— 上游拒绝,本次不改状态,留给下次重试;</li>
+     *   <li>抛 {@link LeshouyingApiException} —— 上游那条腿本身失败了(网络/验签/被拒且
+     *       不是可解释的终局)。**不是**只有前两种出口,别照 null 写调用方。</li>
+     * </ul>
+     *
      * @return 目标状态;返回 null 表示"本次不改状态"(上游拒绝,留给下次重试)
      */
     public String closeOrder(LsyOrderEntity order) {
@@ -131,7 +142,56 @@ public class LsyCancelService {
         }
         p.put("code_url", order.getCodeUrl());
 
-        Map<String, Object> resp = client.post("/v3/qrclose", p);
+        Map<String, Object> resp;
+        try {
+            resp = client.post("/v3/qrclose", p);
+        } catch (LeshouyingApiException e) {
+            // ★ 2026-09-30:**已过期**的聚合码被 `/v3/qrclose` 拒绝时本地收敛为 EXPIRED。
+            //
+            // 【为什么这条拒绝接不住 —— 它不在下面那个分支的形状里】上游把它放在
+            //   `return_code=FAIL` 上,而 `LeshouyingResponseParser.parseV3` 对
+            //   `return_code != SUCCESS` 一律**抛异常** ⇒ `resp` 这个变量根本不会被赋值,
+            //   `isPaySuccess(resp)` 那行永远看不到它。改之前它一路穿过本类飞给调用方:
+            //     · 收银台按钮 → 「撤销/关单未成功,请稍后重试」(再点一次还是这句,永远);
+            //     · 开放 API   → 同上;
+            //     · 调度器 ④   → 带栈 ERROR + 订单留在 PAYING,每 5 分钟重试一次。
+            //   而对外契约(collection-openapi.md 的 FAQ)写的是「有效期默认 15 分钟……
+            //   超时后订单状态变为 EXPIRED」—— 那句要等 24 小时的绝对过期兜底才兑现。
+            //
+            // 【判据是复发的读数】实测三次同向、共 90 次调用 **0 次成功**:2026-09-30 同一笔
+            //   QR1790675073 19 次拒绝;2026-09-22 三笔单 71 次拒绝;而历史上唯一成功过的
+            //   qrclose 是探针拿**刚建出来**的码关的(logs/_lsy-close-control.txt 对照二)。
+            //   ⇒「上游不认这个 code_url 属反常」这个前提被复发率推翻:它就是这个腿的正常遭遇。
+            //
+            // 【为什么还要带 qrCodeLocallyExpired 这个附加条件 —— 它是承重的】
+            //   本类的调用方里只有调度腿自带「已过本地有效期」的保证(扫描谓词就是
+            //   `code_url_expire_time <= now`),**按钮腿可以作用在一张还活着的码上**。
+            //   而我们对「活着的码会不会回这句话」没有任何读数(唯一那条关于活码的读数是
+            //   重复关一笔已 CLOSED 的单,见 LeshouyingOpenController#orderClose 的注释)。
+            //   「本地还活着 + 上游说没了」是两边**互相矛盾**,那是异常不是终局:可能是我们的
+            //   code_url 存坏了,也可能是上游数据不一致。此时把单判死,万一顾客还能付,
+            //   钱就再也记不回来(EXPIRED 不在 LsyOrderStatus.PAY_WRITE_FROM 里,不可逆)
+            //   ⇒ 那种情况保持原样抛出,让按钮如实报失败、让日志留响。
+            //   顺带:这个附加条件让上面那条**联调实测过的**行为一字不变(那时码刚建、
+            //   本地还没过期 ⇒ 仍抛)。
+            //
+            // 【为什么不是 CLOSED】上游回的是**拒绝**,它没关掉这张码;写 CLOSED 等于替上游
+            //   宣布一个它没做的动作,而 CLOSED 还在 LsyOrderStatus.REFUND_WRITE_FROM 里。
+            //   EXPIRED 才是这里的事实:码已失效、谁也付不进来 —— 同本类另两处
+            //   「句柄不可用」分支(空 code_url / 空 trade_no)的取值。
+            //
+            // 【残余风险,写出来不省略】判死的那一刻起,若上游其实还能收这笔钱,本地就再也
+            //   记不回 SUCCESS,只能靠 LsyNotifyService 报一条 LATE_PAYMENT_AFTER_TERMINAL
+            //   ——钱收了、单是「已过期」,账实不符**能被发现,但已经发生了**。可误判的窗口是
+            //   「本地钟已过 code_url_expire_time、而上游那张码还活着」这一小段,与调度腿
+            //   ④⑤ 本来就有的是同一个窗口;取这个方向是因为契约已经在卖这个行为。
+            if (upstreamSaysQrLinkGone(e) && qrCodeLocallyExpired(order)) {
+                log.warn("聚合码已失效,本地收敛为 EXPIRED: outTradeNo={}, upstream={}",
+                        order.getOutTradeNo(), e.getMessage());
+                return "EXPIRED";
+            }
+            throw e;
+        }
         if (parser.isPaySuccess(resp)) {
             return "CLOSED";
         }
@@ -157,6 +217,34 @@ public class LsyCancelService {
         return null;
     }
 
+    /**
+     * 「上游说这个聚合码链接已经没了」的原文片段({@link #closeQrOrder} 用)。
+     *
+     * <p>实测来源:拿一个上游不认的 {@code code_url} 打 {@code /v3/qrclose}、以及关一张
+     * 已过期的码,它回的就是这句(原始字节 {@code logs/_lsy-lc4/qrclose-bogus-url.bin})。
+     * 判据只比**原文片段**,与 {@code LsyReconcileScheduler#upstreamSaysNotFound} 同形 ——
+     * 不比异常类型(异常类型分不出是这一句还是「验签失败」),也不比 `getMessage()`
+     * (那是净化+截断过的,见 {@link LeshouyingApiException})。
+     */
+    private static final String QR_LINK_GONE_MSG = "该聚合码链接不存在或已过期";
+
+    /** 上游原文里带「链接不存在或已过期」。用 {@code getRawReturnMsg()}(原文),日志才用 {@code getMessage()}。 */
+    private static boolean upstreamSaysQrLinkGone(LeshouyingApiException e) {
+        return e.getRawReturnMsg() != null && e.getRawReturnMsg().contains(QR_LINK_GONE_MSG);
+    }
+
+    /**
+     * 本地钟认为这张聚合码已经过期({@code code_url_expire_time} 已到)。
+     *
+     * <p>取不到该字段时返回 false —— **朝「不收敛」的一侧失败**:宁可让按钮如实报失败
+     * (订单还有调度腿④⑤兜底),也不要拿一个没有旁证的上游措辞把订单判死。
+     * EXPIRED 不在 {@code LsyOrderStatus.PAY_WRITE_FROM} 里,写错不可逆。
+     */
+    private static boolean qrCodeLocallyExpired(LsyOrderEntity order) {
+        OffsetDateTime expire = order.getCodeUrlExpireTime();
+        return expire != null && !expire.isAfter(OffsetDateTime.now());
+    }
+
     /** 被扫撤销:先查后撤 */
     private String cancelBarcodeOrder(LsyOrderEntity order) {
         Map<String, Object> q = queryService.queryOrder(order);

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

@@ -281,7 +281,8 @@ public class LsyOrderService {
             //   PAYING 非终态,四处既有机制就地对上:① 在 PAY_WRITE_FROM 内 → 回调可写;
             //   ② PAYING+QRCODE 命中 reconcilePendingOrders 的两个过滤条件 → 对账捞得起;
             //   ③ PAYING ∈ PENDING → 绝对过期兜底;④ 命中 closeExpiredQrOrders 的扫描谓词
-            //      (过期 → 调 qrclose;上游回「链接不存在或已过期」时,2026-09-30 起就地收敛为 EXPIRED)。
+            //      (过期 → 调 qrclose;上游回「链接不存在或已过期」时,2026-09-30 起收敛为 EXPIRED
+            //         —— 判据在 LsyCancelService.closeQrOrder 里,按钮/开放 API/本腿三条调用腿共用)。
             //      ⚠ 别把 ④ 读成「关单能成功」:实测 90 次**到期码**的 qrclose 请求一次都没成功过
             //        (全被上游以「该聚合码链接不存在或已过期!」拒绝),所以 ④ 的实际效果一直是收敛,不是关单。
             //   【与 createBarcodeOrder 的区别】被扫写 SUCCESS 是对的,判据是

+ 59 - 56
java/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.java

@@ -408,97 +408,100 @@ class LsySchedulerTest {
         verify(orderMapper, never()).updateById(any());
     }
 
-    // ============ 关单被拒「该聚合码链接不存在或已过期」⇒ 本地收敛(2026-09-30) ============
+    // ============ 关单被拒「该聚合码链接不存在或已过期」(2026-09-30) ============
     //
-    // 【为什么改】原实现把这条拒绝记成带栈 ERROR 并**什么都不做**:订单留在 PAYING、
-    // 每 5 分钟重试一次,直到 absoluteExpireHours(24 小时)才被绝对过期收走。而对外契约
-    //(collection-openapi.md 的 FAQ「用户一直不付款,二维码会怎样?」)写的是
-    // 「有效期默认 15 分钟(可用 time_expire 指定)。超时后订单状态变为 EXPIRED」——
-    // 实现与契约相反,那个 EXPIRED 要等 24 小时才来。
+    // 【这一段的判据在 2026-09-30 从本类**搬进了服务** —— 所以这里只剩反向守卫】
+    // 原因:收这条拒绝的腿有**三条**(收银台按钮 / 开放 API / 本调度器第 ④ 条),
+    // 把它们各自的 catch 各写一遍,等于同一个上游措辞在三处各判各的、值也可能各写各的。
+    // 现在判在唯一的调用点 `LsyCancelService.closeQrOrder` 上,返回 "EXPIRED",
+    // 本调度器只是照常把它落库(`applyCloseResult`:PENDING 条件更新 + 通知下游)——
+    // 那条落库路径由上面 `applyCloseResult` 的 CLOSED 用例覆盖,此处再写一遍就退化成
+    // 「测 mock 返回了什么」。判据、90 次 0 成功的读数、以及「本地已过期」这个承重的
+    // 附加条件,全部记在 `LsyCancelServiceTest` 的三条用例与那里的注释里。
     //
-    // 【判据是复发的读数,不是形状】改变结论的正是原注释自认「我没有」的那份读数:
-    //   ① 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
-    //     属反常」这个前提被复发率推翻 —— 它就是这个腿的正常遭遇。
+    // 【原正向用例断言过 `patch.finishTime` 非空,现在**故意不写了**】那时收敛走
+    // `markExpired`(它会写 finish_time),现在走 `applyCloseResult`(不写)——
+    // 同一个上游拒绝,从按钮腿收敛和从调度腿收敛落下来的行必须**长得一样**,
+    // 所以取值只能有一条路。这一格的空缺今天不可见,已实测:`pay_lsy_order.finish_time`
+    // **没有读者** —— 本模块里唯一一处 `getFinishTime()` 是 `LsyOrderService.applyPayResult`
+    // 把刚写下的值回填进内存实体(不参与任何判定);前端 `frontend/src` 全文只有**退款**表
+    // 渲染 `finish_time`。写在这里是为了将来有人补这一格时知道它不是被漏掉的。
     //
-    // 【为什么不是 CLOSED】上游**没有**关掉它(它回的是拒绝)。写 CLOSED 等于替上游宣布一个
-    // 它没做的动作,而 CLOSED 还在 LsyOrderStatus.REFUND_WRITE_FROM 里。EXPIRED 才是这里的事实:
-    // 码已失效、谁也付不进来。同族的两处「句柄不可用」分支(closeQrOrder 的 code_url 缺失、
-    // closeH5Order 的 trade_no 缺失)返回的也都是 EXPIRED。
+    /**
+     * 反向守卫:**别的**关单失败必须仍是带栈 ERROR,且一个字都不许改状态。
+     *
+     * <p>没有这一条,一个「凡关单抛异常就本地收敛」的实现能让上面那条全绿 —— 而那是把
+     * **真故障**(验签失败、商户未配置、参数被拒)当成「码已失效」:一笔**还能被支付**的单
+     * 会被本地判死,且再也不重试。失败方向与 {@link LsyReconcileScheduler} 里
+     * {@code upstreamSaysNotFound} 那条一致:判据只能是我们**测过的上游原文**,
+     * 措辞变了就退回吵闹的 ERROR + 保持原状。
+     *
+     * <p>★ 判据搬进服务之后,本条钉的**仍然是调度器这一层的失败方向**:服务对
+     * 「没测过的措辞」不接(见 `LsyCancelServiceTest#qrcode_其他上游失败_仍抛出`),
+     * 抛到这里必须还是 ERROR + 栈 + 状态不动。两条一起才封住「凡异常就收敛」这个错法
+     * ——只测服务那一层时,把调度器 catch 改成无条件收敛不会有任何测试变红。
+     */
     @Test
-    void closeExpiredQrOrders_上游说链接已不存在或已过期_本地收敛为EXPIRED() {
+    void closeExpiredQrOrders_其他上游失败_仍是带栈ERROR且不动状态() {
         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);
+                new LeshouyingApiException("FAIL", "验签失败", "{}"));
 
         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());
+            assertEquals(1, errors(events).size(), "真故障必须留在 ERROR,实测: " + messages(events));
+            assertNotNull(events.stream().filter(e -> e.getLevel() == Level.ERROR)
+                            .findFirst().orElseThrow().getThrowableProxy(),
+                    "ERROR 必须带栈 —— 栈是查这条日志的人唯一能顺下去的东西");
         });
 
-        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());
+        verify(orderMapper, never()).update(any(), any());
+        assertEquals("PAYING", o.getStatus(), "没拿到「码已失效」这条判据时不得改动状态");
+        verify(callbackService, never()).notifyOrder(any());
     }
 
     /**
-     * 反向守卫:**别的**关单失败必须仍是带栈 ERROR,且一个字都不许改状态。
+     * 反向守卫(二):**这一句原文**从服务里抛出来时,调度器也**不得**就地收敛。
      *
-     * <p>没有这一条,一个「凡关单抛异常就本地收敛」的实现能让上面那条全绿 —— 而那是把
-     * **真故障**(验签失败、商户未配置、参数被拒)当成「码已失效」:一笔**还能被支付**的单
-     * 会被本地判死,且再也不重试。失败方向与 {@link LsyReconcileScheduler} 里
-     * {@code upstreamSaysNotFound} 那条一致:判据只能是我们**测过的上游原文**,
-     * 措辞变了就退回吵闹的 ERROR + 保持原状。
+     * <p>它挡的是「把判据搬回调度器 catch」这个回退。判据一旦在服务之外再判一次,
+     * 承载它的那个附加条件(**本地也要已过期**)就不在那条路上了 —— 而那正是
+     * 2026-09-30 这次改判的全部要害:这句话有两个读法(「不存在」**或**「已过期」),
+     * 我们对「本地还活着的码会不会回这句话」没有任何读数,所以两句话必须**同时**成立
+     * 才允许判死(见 `LsyCancelServiceTest` 的段首注释)。
+     *
+     * <p>服务在「本地还没过期」时会**故意**把它抛出来(那是两边互相矛盾,是个异常)。
+     * 调度器跟着判死,就等于把那个矛盾当成终局:EXPIRED 不在 PAY_WRITE_FROM 里,
+     * 一笔可能还能被支付的单会被永久判死。
      *
-     * <p>本条在加实现**之前**就是绿的(钉的是既有行为),价值由变异证明:
-     * 把收敛改成无条件 ⇒ 本条必须红。
+     * <p>本条在搬判据**之前**是绿的(当时这句由调度器自己接住),价值由变异证明:
+     * 在 catch 里加回一个按这句原文收敛的分支 ⇒ 本条必须红。
      */
     @Test
-    void closeExpiredQrOrders_其他上游失败_仍是带栈ERROR且不动状态() {
+    void closeExpiredQrOrders_服务原样抛出这一句_调度器不得就地收敛() {
         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", "验签失败", "{}"));
+                new LeshouyingApiException("FAIL", "该聚合码链接不存在或已过期!", "{}"));
 
         withLogCapture(LsyReconcileScheduler.class, events -> {
             assertDoesNotThrow(() -> scheduler().closeExpiredQrOrders());
 
-            assertEquals(1, errors(events).size(), "真故障必须留在 ERROR,实测: " + messages(events));
+            assertEquals(1, errors(events).size(),
+                    "服务既然把它抛出来,就说明本地旁证没成立 —— 那是异常,必须留 ERROR,实测: "
+                            + messages(events));
             assertNotNull(events.stream().filter(e -> e.getLevel() == Level.ERROR)
-                            .findFirst().orElseThrow().getThrowableProxy(),
-                    "ERROR 必须带栈 —— 栈是查这条日志的人唯一能顺下去的东西");
+                            .findFirst().orElseThrow().getThrowableProxy(), "ERROR 必须带栈");
         });
 
         verify(orderMapper, never()).update(any(), any());
-        assertEquals("PAYING", o.getStatus(), "没拿到「码已失效」这条判据时不得改动状态");
+        assertEquals("PAYING", o.getStatus(), "判据不成立时不得改动状态(本层的判据已搬进服务)");
         verify(callbackService, never()).notifyOrder(any());
     }
 

+ 121 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCancelServiceTest.java

@@ -1,5 +1,10 @@
 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;
+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;
@@ -8,10 +13,14 @@ 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;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Consumer;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.*;
@@ -356,4 +365,116 @@ class LsyCancelServiceTest {
         assertEquals("EXPIRED", service.closeOrder(o));
         verify(client, never()).post(eq("/v3/qrclose"), anyMap());
     }
+
+    // ============ 上游说「该聚合码链接不存在或已过期」⇒ 本地收敛(2026-09-30) ============
+    //
+    // 【为什么改】这条拒绝**不是**上面那条 `resp()` 分支能接住的形状:上游把它放在
+    // `return_code=FAIL` 里,`LeshouyingResponseParser.parseV3` 对 `return_code != SUCCESS`
+    // 一律**抛异常** ⇒ 它根本不进 `isPaySuccess(resp)` 那个 if,直接穿过本类飞给调用方。
+    // 于是三条调用腿(收银台按钮 / 开放 API / 调度器第 ④ 条腿)收到的都是同一个异常:
+    //   · 收银台按钮 → `Result.of(500, MSG_CLOSE_FAILED)`「撤销/关单未成功,请稍后重试」;
+    //     操作员再点一次还是这句,**重试永远不可能成功** —— 码已经不在上游了。
+    //   · 开放 API   → 同上(`Result.fail(500, "撤销/关单未成功,请稍后重试")`)。
+    //   · 调度器     → 带栈 ERROR,订单留在 PAYING,每 5 分钟重试一次,直到 24 小时的
+    //     绝对过期阈值才收走。而对外契约(`collection-openapi.md` 的 FAQ)写着
+    //     「有效期默认 15 分钟 …… 超时后订单状态变为 EXPIRED」—— 实现与契约相反。
+    //
+    // 【判据是复发的读数,不是形状】实测三次同向:2026-09-30 那次运行同一笔 QR1790675073,
+    // 19 次 `/v3/qrclose` / 19 条这条拒绝 / **0 次成功**;2026-09-22 那份 3 笔单 71 次拒绝、
+    // 同样 0 次成功;而历史上**唯一成功过**的 qrclose 是探针拿**刚建出来**的码关的
+    // (`logs/_lsy-close-control.txt` 对照二,建单后约 1 秒)。⇒ 90 次实测、0 次成功。
+    //
+    // 【为什么带「本地已过期」这个附加条件 —— 它是承重的】本类**四个**调用方里,
+    // 只有调度器那条腿自带「已过本地有效期」的保证(扫描谓词)。按钮那条腿可以作用在
+    // **一张还活着的码**上,而我们对「活着的码会不会回这句话」**没有任何读数**。
+    // 两个读法分开看:
+    //   · 本地已过期 + 上游说没了 ⇒ 两句话说的是同一件事 ⇒ 收敛(下面第 1 条)。
+    //   · 本地**还**没过期 + 上游说没了 ⇒ 两边**互相矛盾** —— 可能是我们的 code_url 存坏了,
+    //     也可能是上游的数据不一致。这是个**异常**,不是可解释的终局:此时把订单判死,
+    //     万一顾客还能付,钱就再也记不回来了(EXPIRED 不在 PAY_WRITE_FROM 里)。
+    //     ⇒ 保持原样抛出(下面第 2 条),让按钮如实报失败、让 ERROR 留在日志里。
+    // 这个附加条件还让一处**联调实测过的**行为一字不变:重复关一笔**已 CLOSED** 的单,
+    // 上游回的也是这句话(`LeshouyingOpenController` 的注释里有这条读数)。那种单本地
+    // `code_url_expire_time` 通常**还没到**(刚建的码被关掉),走第 2 条 —— 与今天完全一样。
+    //
+    // 【为什么不是 CLOSED】上游回的是**拒绝**,它没关掉这张码;写 CLOSED 等于替上游宣布
+    // 一个它没做的动作,而 CLOSED 还在 `LsyOrderStatus.REFUND_WRITE_FROM` 里。EXPIRED
+    // 才是这里的事实:码已失效、谁也付不进来 —— 同本类另两处「句柄不可用」分支的取值。
+
+    @Test
+    void qrcode_上游说链接不存在或已过期_本地已过期_返回EXPIRED不抛出() {
+        when(client.post(eq("/v3/qrclose"), anyMap())).thenThrow(
+                new LeshouyingApiException("FAIL", "该聚合码链接不存在或已过期!", "{}"));
+
+        LsyOrderEntity o = qrcode();
+        o.setCodeUrlExpireTime(OffsetDateTime.now().minusMinutes(1));   // 本地也已过期
+
+        withLogCapture(LsyCancelService.class, events -> {
+            String target = assertDoesNotThrow(() -> service.closeOrder(o),
+                    "码已失效是可解释的终局,不得把异常抛给调用方(按钮会报「请稍后重试」)");
+
+            assertEquals("EXPIRED", target, "取值是 EXPIRED,不是 CLOSED(上游没关掉它)");
+
+            List<ILoggingEvent> warns = events.stream()
+                    .filter(e -> e.getLevel() == Level.WARN).toList();
+            assertEquals(1, warns.size(), "应恰有一条收敛 WARN,实测: " + messages(events));
+            assertNull(warns.get(0).getThrowableProxy(), "收敛路径不挂栈");
+            assertTrue(warns.get(0).getFormattedMessage().contains("该聚合码链接不存在或已过期"),
+                    "上游原文必须留下 —— 否则排障时看不出是哪种拒绝,实测: "
+                            + warns.get(0).getFormattedMessage());
+        });
+
+        // 只打一次上游:不因为被拒就换参数重试(同一个 code_url 重试没有意义)
+        verify(client, times(1)).post(eq("/v3/qrclose"), anyMap());
+    }
+
+    /**
+     * 反向守卫(一):**本地还没过期**时,同一句话不构成判死的依据。
+     *
+     * <p>没有这一条,一个「凡看到这句话就收敛」的实现能让上面那条全绿 —— 而那是拿一句
+     * 上游措辞去覆盖一个**两边互相矛盾**的状态:本地钟说这张码还能用到 T,上游说它没了。
+     * 那种矛盾要么说明我们的 `code_url` 存坏了,要么说明上游数据不一致,**两种都该响**,
+     * 而不是把一笔可能还能付的单静默判死(EXPIRED 不在 `PAY_WRITE_FROM` 里,写错不可逆)。
+     */
+    @Test
+    void qrcode_同一句话但本地还没过期_仍抛出() {
+        when(client.post(eq("/v3/qrclose"), anyMap())).thenThrow(
+                new LeshouyingApiException("FAIL", "该聚合码链接不存在或已过期!", "{}"));
+
+        LsyOrderEntity o = qrcode();
+        o.setCodeUrlExpireTime(OffsetDateTime.now().plusMinutes(10));   // 本地钟说它还活着
+
+        assertThrows(LeshouyingApiException.class, () -> service.closeOrder(o),
+                "本地与上游互相矛盾时不得判死,必须抛给调用方");
+    }
+
+    /** 反向守卫(二):**别的**关单失败一个字都不许改 —— 判据只能是这一句原文。 */
+    @Test
+    void qrcode_其他上游失败_仍抛出() {
+        when(client.post(eq("/v3/qrclose"), anyMap())).thenThrow(
+                new LeshouyingApiException("FAIL", "验签失败", "{}"));
+
+        LsyOrderEntity o = qrcode();
+        o.setCodeUrlExpireTime(OffsetDateTime.now().minusMinutes(1));
+
+        assertThrows(LeshouyingApiException.class, () -> service.closeOrder(o),
+                "「验签失败」这类真故障必须照旧抛出去,不能被当成码失效");
+    }
+
+    /** 与 LsyQrPayTest 同款(本仓库按测试文件各自持有一份,不抽公共类)。 */
+    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<String> messages(List<ILoggingEvent> events) {
+        return events.stream().map(ILoggingEvent::getFormattedMessage).toList();
+    }
 }