Forráskód Böngészése

fix(退款): 对账腿换游标调度 - 消除老行饿死新行(台账 §2.3 裁定)

旧谓词 created_time <= now - reconcileDelaySeconds 是**单调**的(一旦为真永远为真),
而卡住的退款行根本不写库(settleFromQuery 的 PAY_IN_PROCESS 分支与未知码分支都直接
return null,一次 mapper 调用都没有) ⇒ 元组不移位 ⇒ 每轮 LIMIT 100 稳定取到同一批,
后面的行永远轮不到。饿死是**稳态**而非概率,且门槛很低:「卡住」不需要上游出错,
PAY_IN_PROCESS 是上游文档认可的合法长态;100 行 ÷ 3 天 ≈ 34 笔/天就顶满配额。

机制抄仓内先例(pay_lsy_order.next_query_time + LsyOrderPollScheduler):
- 谓词换成非单调的游标 next_query_time <= now。它与下面那句 ORDER BY 是配套的、
  不可拆:只换谓词不加排序取到的仍是任意一批;只加排序不换谓词,单调谓词会让最老的
  一批被反复取到,饿死反而从「任意」升级成「确定」。
- ORDER BY next_query_time ASC,每轮处理完把游标推到 now + reconcileDelaySeconds。
  处理过的行新值严格大于所有「已到期且本轮未处理」的行 ⇒ 下一轮选中的 100 行与本轮
  选中的(仍留 PENDING 的)不相交 ⇒ 每行 ⌈N/100⌉ 轮内必被问到。
- 推游标放在 try 之外、循环体末尾:异常路径连 settleFromQuery 都没走到,按返回值分支
  会把这类漏掉,而它们同样能把 LIMIT 100 的头部占死。
- 预插入时写游标初值(now + delay)。必须非 NULL:SQL 里 next_query_time <= now 对 NULL
  求值为 NULL 而不是 true ⇒ 漏写这一列的行永远不进对账集,且整个过程静默。
  刻意**不**在谓词里加 OR next_query_time IS NULL 兜底,那是同一个缺口的复辟。
- 守卫写在 WHERE 里(id = ? AND status IN PENDING),本模块唯一允许的条件更新写法。

不做的事(逐条有依据,见台账):
- 不加 query_count:pay_transfer.next_retry_at 就是「只写不读」的重试列,加了是重犯。
- 不改订单侧那两条腿:它们的谓词同样单调,但 expireStaleOrders 是排水阀 ⇒ 集合有界 ⇒
  不存在永久饿死。这个不对称正是只有退款腿需要游标的原因。
- 不做指数退避:那要发明一个上限数字,而没有依据。留触发条件:N 持续 >1000 再议。

新增 db/lsy-refund-reconcile-cursor.sql(加列 + 加索引 + 回填)。V1.8 用的是
CREATE TABLE IF NOT EXISTS,判据是关系名不是定义 ⇒ 表已建,改 V1.8 会静默 no-op,
必须单独重发一次。
★ 本 SQL 必须在部署新代码**之前**跑完,否则扫一个不存在的列会抛 PG 42703。
回填取 created_time(立刻到期,方向安全:早问只多一次上游查询);回填不带 status 条件,
避免在 SQL 里再抄一份 IN ('CREATED','REFUNDING')。

测试:全量 411 绿 / 0 失败(改前 408,差值 = 新增 3 条)。
两向变异实测:orderByAsc→Desc ⇒ 恰好 1 条红(失败消息打出真实 SQL 段,顺带实证
Wrapper.getSqlSegment() 含 ORDER BY);删掉 pushNextReconcileTime(refund) ⇒ 恰好 3 条红;
无害重排 patch 两个 setter ⇒ 21 全绿。还原后与变异前备份 diff 为空才跑的全量。
alphaH 3 hete
szülő
commit
e4e4941c3d

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

@@ -303,13 +303,107 @@
 且文档明说「`PAY_IN_PROCESS` 需调用退款查询接口确认退款结果」⇒ **查询是被指定的权威确认通道**,
 所以这条腿敢在 `PAY_FAIL` 上落 FAIL(`createRefund` 的同步分支仍守闸 —— 它手上没有权威答案)。
 
-**⚠ 本腿未覆盖的(新记录,需裁定)**:它**只扫非终态**,所以「已落终态的行收到迟到成功」
+**⚠ 本腿未覆盖的(新记录 → 2026-09-22 已裁定,见下)**:它**只扫非终态**,所以「已落终态的行收到迟到成功」
 (`LATE_REFUND_AFTER_TERMINAL`)仍然只有事后探测。另外它**没有上界** —— 退款行不存在安全的
 本地终态(写 FAIL 会释放两道防重复退款的锁),所以做不出订单侧 `absoluteExpireHours` 那样的
 强制收敛;一个永远拿不到权威答案的行会被无限期每轮问一次,并占着 `LIMIT 100` 的配额,
 **老行堆积后会静默饿死新行**。上界该取多少我没有上游依据,故不发明 —— 见增量计划
 `.claude/plan/2026-09-22-refund-reconciliation.md` 的「本增量引入的新缺口」。
 
+#### ✅ 裁定:饿死缺口(2026-09-22)
+
+**结论:不造终态、也不取「时间上界」;给退款行加一个**会前进的进度游标** `next_query_time`,
+把扫描从「单调谓词 + 任意 100 条」换成「非单调谓词 + 按游标升序的 100 条」。**
+
+**根因(结构性的,不是 `LIMIT` 太小、也不是「少了个 ORDER BY」)**
+
+1. 谓词 `created_time <= now - 60s` 是**单调**的:一旦为真永远为真 ⇒ 候选集**只进不出**。
+2. 卡住的行**根本不写库**:`LsyRefundService.settleFromQuery` 的 `PAY_IN_PROCESS` 分支与未知码分支
+   都直接 `return null`,**一次 mapper 调用都没有**(`applyPayResult` 那条路只在真流转时才写,
+   命中 0 行的条件更新同样不改元组)。⇒ 这些行的 `created_time` 不动、堆里的物理位置也不动。
+3. 1 + 2 合起来 ⇒ 同一条 SQL、同一个执行计划、同一批元组 ⇒ **即使没有 `ORDER BY`,返回的也是同一批**。
+   最可能的计划是走 `uk_lsy_refund_one_pending`(它的谓词 `status IN ('CREATED','REFUNDING')`
+   与本查询谓词**完全重合**,部分索引直接可用),按 `order_id` 升序扫 ≈ 雪花号升序 ≈ **最老的先返回**——
+   恰好是最坏的那种「稳定」。
+4. ⇒ 饿死**不是概率事件,是稳态**。`LIMIT 100` 之外的行永远不会被问到。
+
+**触发门槛比直觉低**:「卡住」**不需要上游出错**。`PAY_IN_PROCESS` 是上游文档认可的合法长态
+(银行卡退款按天计),所以积压=在途退款量本身。**100 行 ÷ 3 天 ≈ 34 笔/天**就够顶满配额。
+这不是病态场景,是正常放量就会踩到的。
+
+**为什么必须修(伤害面)**:§2.1 刚堵出来的那个缺口 —— `post` 超时后留在 `CREATED` 的行 ——
+**没有回调会来**(上游可能压根没收到那笔请求),对账腿是它**唯一**的出口。
+把对账腿饿死 = 让那个缺口重新变成无界。
+
+**为什么不能照订单侧取一个时间上界**:`absoluteExpireHours` 安全,是因为「把未支付的订单收敛为
+EXPIRED」是**安全方向**;退款行没有对应物 —— 写 FAIL 会**同时**释放 `uk_lsy_refund_one_pending`
+与在途闸门 ⇒ 重复出金。⇒ 「上界该取多少」这个问法本身就问错了:这里缺的**不是一个数字**。
+
+**机制(仓内先例,不是新发明)**:`pay_lsy_order` 已有同名列 ——
+`next_query_time TIMESTAMP WITH TIME ZONE`,列注释原文「下次查询时间(**退避调度**)」,
+配套索引 `idx_lsy_order_poll (status, pay_scene, next_query_time)`,用法见
+`LsyOrderPollScheduler.pollBarcodeOrders`:`.le(nextQueryTime, now).orderByAsc(nextQueryTime).last("LIMIT 100")`。
+退款侧照抄形状:insert 时 `next_query_time = now + reconcileDelaySeconds`;
+扫描 `status IN LsyRefundStatus.PENDING AND next_query_time <= now ORDER BY next_query_time ASC LIMIT 100`;
+**每轮处理完(无论收没收敛)都把它推到 `now + reconcileDelaySeconds`**。
+
+**无饿死的证明**:被本轮处理过的行,其新值 `now + delay` **严格大于** `now ≥` 所有「已到期且本轮未处理」
+的行的值 ⇒ 下一轮选中的 100 行与本轮选中的(仍留在 `PENDING` 的)**不相交** ⇒ 每一行都在
+⌈N/100⌉ 轮内必被问到(N=当轮到期行数)。等待时间**与积压量成正比,而不是无穷**。
+
+**不做的事(含理由)**
+
+- **不加 `query_count`**。不是「用不上」,是仓内已有前车之鉴:`pay_transfer.next_retry_at` 就是一列
+  「只写不读」的重试列,被记为本项目既有缺陷。固定间隔的轮转里它**没有任何读者** ⇒ 加了就是重犯。
+- **不改订单侧那两条腿**(`reconcilePendingOrders` / `closeExpiredQrOrders`)。它们的谓词同样单调
+  (`code_url_expire_time <= now` 也一样),但候选集**有排水阀**:`expireStaleOrders` 会在
+  `h5ExpireMinutes` / `absoluteExpireHours` 之后把它们移出非终态 ⇒ 集合有界 ⇒ 不存在永久饿死。
+  **这个不对称正是「只有退款腿需要游标」的原因**,不是遗漏。
+- **不动 `LIMIT 100`**:新机制下它不再是饿死的成因。
+- **不做指数退避**:那需要发明一个上限数字(正是没有依据的东西),而固定间隔的轮转已经消除了无界
+  失败模式。留一个将来可判的触发条件:**若 N 长期 ≫ 100(例如持续 >1000),再考虑退避**——
+  届时 `query_count` 才第一次有了读者。
+
+**代价与落地约束**
+
+- 一行 DDL(新增列 + 索引)。**必须先执行 SQL 再发版**:代码会查一个不存在的列。
+- **存量非终态行必须回填** `next_query_time`。不回填的后果是静默的:`next_query_time <= now`
+  对 `NULL` 求值为 `NULL`(不是 true)⇒ 那些行**永远不进对账集**。
+- 列类型必须 `TIMESTAMP WITH TIME ZONE`、实体字段用 `OffsetDateTime`(与 `pay_lsy_order.next_query_time` 一致)——
+  本仓有过「全库时间列漂移」的实测缺陷,此处再开一个 `timestamp without time zone` 就是重开那个口子。
+- 插入口只有一处(`createRefund` 的预插入),且已有测试捕获该实体 ⇒ 补一条
+  「插入时 `nextQueryTime` 非空」的断言即可钉住 NULL 这条路。**不**在查询谓词里加
+  `OR next_query_time IS NULL` 兜底:那会把 NULL 行重新变成「永远到期」,是同一个缺口的复辟。
+
+**实施结果(2026-09-22,已入库未提交)**
+
+落了 5 个文件:`LsyRefundEntity`(加 `nextQueryTime`)、`LsyRefundService`(预插入时写初值
+`now + reconcileDelaySeconds`,多一个只为游标初值而依赖的 `LeshouyingProperties`)、
+`LsyReconcileScheduler`(换谓词 + `ORDER BY next_query_time ASC` + 每轮末 `pushNextReconcileTime`,
+守卫写在 WHERE 里)、`V1.8` 建表文件(三处,供全新安装与 `LsyEntityMappingTest` 双向校验)、
+**新建** `db/lsy-refund-reconcile-cursor.sql`(见下)。测试 +3 条。
+
+读数(每次跑前删 `target/surefire-reports/`):
+
+| 项 | 读数 |
+|---|---|
+| 全量 | **411 绿 / 0 失败**,BUILD SUCCESS(改前 408,差值 = 新增 3 条,自洽) |
+| `LsySchedulerTest` | 21 绿;`LsyRefundServiceTest` 34 绿 |
+| **变异 M1**(`orderByAsc`→`Desc`,**必须红**) | **恰好 1 条红**,正是那条排序断言。失败消息把真实 SQL 段打了出来:`(status IN (…) AND next_query_time <= …) ORDER BY next_query_time DESC LIMIT 100` —— 顺带**实证**了 `getSqlSegment()` 包含 `ORDER BY`(此前只是推测) |
+| **变异 M2**(删掉 `pushNextReconcileTime(refund);`,**必须红**) | **恰好 3 条红**,正是断言「推游标」的那三条(未收敛 / 抛异常 / 非终态守卫) |
+| **变异 M3**(把 `patch.setNextQueryTime` 与 `setUpdatedTime` 两行对调,**无害,必须仍绿**) | **21 绿**——测试没有对无语义的语句顺序过敏 |
+| 还原核对 | 变异后文件与变异前备份 `diff` 为空(逐字节相同)才跑的全量 |
+
+⇒ 断言**双向**都实测了:该红的红、该绿的绿;红的那两条读数说明这些用例**真的在测这个机制**,
+而不是碰巧通过。
+
+**⚠ 未完成的一步(阻塞发版):`db/lsy-refund-reconcile-cursor.sql` 尚未执行。**
+
+我没有执行 DDL 的权限(本项目规矩:SQL 我写、由你跑)。该文件含加列 + 加索引 + 回填,
+**必须在部署新代码之前跑完**——新代码的扫描条件含 `next_query_time`,列不存在会抛 PostgreSQL `42703`。
+回填取 `created_time`(即立刻到期),方向安全;回填不带 `status` 条件,避免在 SQL 里抄第三份
+`IN ('CREATED','REFUNDING')`。文件末尾有三句核对 SQL(列类型 / NULL 计数期望 0 / 索引定义)。
+
 ### 2.4 ~~下游回调重试通道**当前完全不通**~~ —— **已不成立(2026-09-22 复核实测)**
 
 ⚠ **本节原文写于 `LsyReconcileScheduler` 类文件还不存在时,两条腿现在都通。** 保留原文是为了

+ 13 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/entity/LsyRefundEntity.java

@@ -41,6 +41,19 @@ public class LsyRefundEntity extends PaymentTenantBaseEntity {
     private Long refundSettle;
     private String upstreamResultCode;
     private String upstreamResultMsg;
+    /**
+     * 下次对账时间(退避调度)。
+     *
+     * <p>★ 非终态行**必须非 NULL**。{@code LsyReconcileScheduler.reconcilePendingRefunds} 的扫描
+     * 谓词是 {@code next_query_time <= now},而 SQL 里这个比较对 NULL 求值为 NULL(**不是** true)
+     * ⇒ 漏写这一列的行**永远不进对账集**,且整个过程静默。
+     *
+     * <p>为什么不用 {@code created_time} 做这个谓词:它是**单调**的(一旦为真永远为真),
+     * 而卡住的行根本不写库 ⇒ 每轮取到的是同一批,{@code LIMIT 100} 之外的行永远轮不到。
+     * 这是「饿死」缺口的机制本体,裁定见台账 §2.3。写入点只有两处:{@code createRefund}
+     * 的预插入,与调度器每轮处理后的 {@code pushNextReconcileTime}。
+     */
+    private OffsetDateTime nextQueryTime;
     private String refundRequestTime;
     private String originalPayTime;
     private String notifyStatus;

+ 49 - 4
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java

@@ -174,22 +174,40 @@ public class LsyReconcileScheduler {
     }
 
     /**
-     * 兜底:非终态且已超过 reconcileDelaySeconds 的退款行,查一次上游。
+     * 兜底:**对账游标到期**的非终态退款行,查一次上游。
      *
      * <p>形状对齐 {@link #reconcilePendingOrders}:扫表 → 调 service → 记日志。
      * 结果码怎么解释、两行(退款行 + 订单行)怎么落库,全在
      * {@link LsyRefundService#settleFromQuery} —— 本方法一个上游字段都不解析。
+     *
+     * <p>★ 与订单腿的**唯一结构差异**是扫描谓词(`created_time` → `next_query_time`)
+     * 与循环体末尾那次 {@link #pushNextReconcileTime}。订单腿**不需要**它们:订单侧有
+     * {@link #expireStaleOrders} 这个排水阀(到点把非终态订单强制收敛为 EXPIRED),
+     * 对账候选集因此**有界**,不存在饿死;退款侧**没有**安全的本地终态可落
+     * (写 FAIL 会同时释放 uk_lsy_refund_one_pending 与在途闸门 ⇒ 重复出金),
+     * 候选集**只进不出**。
+     *
+     * <p>★ 所以这里只能靠**调度**而不是**状态**来排空。旧谓词 `created_time <= 阈值` 是
+     * **单调**的(一旦为真永远为真),而卡住的行根本不写库({@code settleFromQuery} 的
+     * 在途/未知码分支一次 mapper 调用都没有)⇒ 元组不移位 ⇒ 每轮取到的是**同一批**,
+     * `LIMIT 100` 之外的行**永远轮不到**。换成会前进的游标 + 升序取 + 每轮推进之后:
+     * 处理过的行新值 `now + delay` **严格大于**所有「已到期且本轮未处理」的行
+     * ⇒ 下一轮选中的 100 行与本轮选中的(仍留在 PENDING 的)**不相交** ⇒ 每行都在
+     * ⌈N/100⌉ 轮内必被问到。裁定与证明见台账 §2.3「裁定:饿死缺口」。
      */
     void reconcilePendingRefunds() {
-        OffsetDateTime threshold = OffsetDateTime.now()
-                .minusSeconds(props.getReconcileDelaySeconds());
         List<LsyRefundEntity> refunds = refundMapper.selectList(
                 new LambdaQueryWrapper<LsyRefundEntity>()
                         // 用常量而不是字面量 List.of("CREATED","REFUNDING"):这正是
                         // LsyRefundStatus.PENDING 的定义,写死会让它与部分唯一索引谓词各改各的
                         // (那份一致性由 LsyEntityMappingTest 钉住)。
                         .in(LsyRefundEntity::getStatus, LsyRefundStatus.PENDING)
-                        .le(LsyRefundEntity::getCreatedTime, threshold)
+                        // ★ 谓词是**非单调**的游标,不是 created_time(后者一旦为真永远为真)。
+                        //   它与下面那句 ORDER BY 是**配套的、不可拆**:只换谓词不加排序,取到的
+                        //   仍是任意的一批;只加排序不换谓词,谓词单调 ⇒ 最老的一批被反复取到,
+                        //   饿死反而变成**确定性**的。
+                        .le(LsyRefundEntity::getNextQueryTime, OffsetDateTime.now())
+                        .orderByAsc(LsyRefundEntity::getNextQueryTime)
                         .last("LIMIT 100"));
 
         for (LsyRefundEntity refund : refunds) {
@@ -203,9 +221,36 @@ public class LsyReconcileScheduler {
             } catch (Exception e) {
                 log.error("退款对账查询异常: outRefundNo={}", refund.getOutRefundNo(), e);
             }
+            // ★ 三种情形都必须推:
+            //   (a) 未收敛(上游回 PAY_IN_PROCESS / 未知码)—— 这类行**一次库都不写**,
+            //       不推就永远停在游标头部;
+            //   (b) 已收敛 —— 行已离开非终态,由 WHERE 里的守卫挡掉(命中 0 行);
+            //   (c) 抛异常 —— 它连 settleFromQuery 都没走到,**按返回值分支会把这类漏掉**,
+            //       而它们同样能把 LIMIT 100 的头部占死。
+            //   放在 try 之外、循环体末尾:异常路径也走到,且不必再引一个 finally 分支。
+            pushNextReconcileTime(refund);
         }
     }
 
+    /**
+     * 把退款行的对账游标推到下一个周期。**这是消除饿死的机制本体。**
+     *
+     * <p>守卫写在 WHERE 里(本模块唯一允许的条件更新写法):这一行若已经离开非终态
+     * (回调 / 同步分支抢先收敛),推游标没有意义,让它命中 0 行即可 ——
+     * 不拿返回值当守卫。
+     */
+    private void pushNextReconcileTime(LsyRefundEntity refund) {
+        LsyRefundEntity patch = new LsyRefundEntity();
+        patch.setNextQueryTime(OffsetDateTime.now()
+                .plusSeconds(props.getReconcileDelaySeconds()));
+        patch.setUpdatedTime(OffsetDateTime.now());
+
+        UpdateWrapper<LsyRefundEntity> w = new UpdateWrapper<>();
+        w.eq("id", refund.getId())
+         .in("status", LsyRefundStatus.PENDING);
+        refundMapper.update(patch, w);
+    }
+
     /** 聚合码到期 → 调 v3/qrclose 关单 */
     void closeExpiredQrOrders() {
         List<LsyOrderEntity> orders = orderMapper.selectList(

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

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 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.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
 import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus;
@@ -65,6 +66,12 @@ public class LsyRefundService {
      * 无循环依赖:LsyCallbackService 只依赖 mapper / RestTemplate / 配置,不认识任何 Service。
      */
     private final LsyCallbackService callbackService;
+    /**
+     * 只为对账游标的**初值**({@code reconcileDelaySeconds})而依赖。
+     * 对账腿本身在 {@code LsyReconcileScheduler},那里也读同一个旋钮——同一个语义只有一个来源,
+     * 两处不会各改各的。
+     */
+    private final LeshouyingProperties props;
 
     /**
      * 申请退款。支持部分退款。
@@ -192,6 +199,14 @@ public class LsyRefundService {
         entity.setNotifyCount(0);
         // 上游来源的 refund_no / qd_refund_no / refund_settle 此刻**还不知道**,收到响应后补
         entity.setStatus(LsyRefundStatus.CREATED.name());
+        // ★ 对账游标:插入时定好第一次对账的时间,此后由 LsyReconcileScheduler 每轮推进。
+        //   必须是**非 NULL** 的 —— 扫描谓词 `next_query_time <= now` 对 NULL 求值为 NULL
+        //   (不是 true)⇒ 漏了这一句,这一行**永远不进对账集**,而且是静默的。
+        //   取值即原有的「先等一轮再问」语义:刚提交的退款立刻问上游必然回 PAY_IN_PROCESS。
+        //   ★ 本方法是 pay_lsy_refund 的**唯一** insert 点(全仓 grep `refundMapper.insert` 只有这里),
+        //   所以这一句钉住了全部行的初值。
+        entity.setNextQueryTime(OffsetDateTime.now()
+                .plusSeconds(props.getReconcileDelaySeconds()));
         refundMapper.insert(entity);
 
         Map<String, Object> resp;

+ 78 - 0
java/src/main/resources/db/lsy-refund-reconcile-cursor.sql

@@ -0,0 +1,78 @@
+-- 退款对账游标:给 pay_lsy_refund 加 next_query_time + 索引 + 回填
+--
+-- 【为什么需要单独一个文件】
+-- V1.8 用的是 `CREATE TABLE IF NOT EXISTS` —— 它的判据是**关系名**,不是定义。
+-- 建表语句执行过之后,对 V1.8 里 `pay_lsy_refund` 的**任何修改都静默失效**
+-- (只发一条 NOTICE,什么都不做)。而 V1.8 已经执行过(2026-09-22 实测:两张表、
+-- 全部索引都在库里)。所以新增列必须在**这里**重发一次,否则文件与库分叉。
+--
+-- 【改的是什么】
+-- 新增对账游标。它替代 `LsyReconcileScheduler.reconcilePendingRefunds` 原先的扫描谓词
+-- `created_time <= now - reconcileDelaySeconds`。
+--
+-- 旧谓词是**单调**的(一旦为真永远为真),而卡住的退款行根本不写库
+-- (`LsyRefundService.settleFromQuery` 的在途 / 未知码分支一次 mapper 调用都没有)
+-- ⇒ 元组不移位 ⇒ 每轮 `LIMIT 100` 取到的是**同一批**,后面的行**永远轮不到**。
+-- 饿死因此是**稳态**而非概率事件,且门槛不高:「卡住」不需要上游出错 ——
+-- `PAY_IN_PROCESS` 是上游文档认可的合法长态(银行卡退款按天计),
+-- 100 行 ÷ 3 天 ≈ 34 笔/天就顶满配额。
+--
+-- 新机制:谓词换成会前进的游标 + `ORDER BY next_query_time ASC` + 每轮处理完把游标推到
+-- `now + reconcileDelaySeconds`。处理过的行新值**严格大于**所有「已到期且未处理」的行
+-- ⇒ 每行都在 ⌈N/100⌉ 轮内必被问到。等待时间与积压成正比,不再无穷。
+-- 裁定与完整证明见台账 §2.3「裁定:饿死缺口」:
+-- docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md
+--
+-- 【⚠ 执行顺序:本文件必须在部署新代码**之前**跑完】
+-- 新代码的扫描条件含 `next_query_time`,列不存在时会抛 PostgreSQL 42703。
+--
+-- 【回填为什么把游标设成 created_time(而不是 created_time + 60s)】
+-- 回填行会因此**立刻到期**,比新代码给新行设的初值早一轮。这是有意的,且方向安全:
+-- 早问一次只是多一次上游查询;晚问/不问才会让 §2.1 那个「post 超时后留在 CREATED 的行」
+-- 重新变成无界(那种行**没有回调会来**,对账腿是它唯一的出口)。
+-- 刻意不在 SQL 里写死 60 —— `reconcileDelaySeconds` 是配置项,抄一份到这里就是第三份副本。
+--
+-- 【回填为什么不带 status 条件】
+-- 也是刻意的:带上就要在 SQL 里再抄一遍 `IN ('CREATED','REFUNDING')`,
+-- 而那个集合已经有两份副本(Java 的 LsyRefundStatus.PENDING 与部分唯一索引谓词,
+-- 由 LsyEntityMappingTest 钉住)。终态行带上游标没有任何副作用(它们永远不进扫描集),
+-- 所以这里回填**全部** NULL 行,把不变量收成最简单的一条:**本表不留 NULL 游标**。
+--
+-- 【安全性】纯加列 + 加索引 + 回填,**不改任何已有列、不动任何已有数据**。
+-- 除回填外全部幂等(ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS / COMMENT ON);
+-- 回填带 `WHERE next_query_time IS NULL`,重复执行是空操作。
+--
+-- 【执行方式】psql -f 本文件,或用任意客户端逐条跑。跑完用文件末尾三句核对。
+
+ALTER TABLE pay_lsy_refund
+    ADD COLUMN IF NOT EXISTS next_query_time TIMESTAMP WITH TIME ZONE;
+
+-- 类型必须显示为 `timestamp with time zone`。本仓有过「全库时间列漂移」的实测缺陷
+-- (pgjdbc 读 timestamp 挂 UTC ⇒ 每次 updateById 漂 +8h),此处用 without time zone
+-- 就是重开那个口子。同库同族的 pay_lsy_order.next_query_time 也是带时区的。
+COMMENT ON COLUMN pay_lsy_refund.next_query_time IS '下次对账时间(退避调度)';
+
+-- 形状对齐 idx_lsy_order_poll (status, pay_scene, next_query_time):
+-- 谓词 `status IN (PENDING) AND next_query_time <= now ORDER BY next_query_time`
+-- 正好落在 (status, next_query_time) 上。
+CREATE INDEX IF NOT EXISTS idx_lsy_refund_reconcile
+    ON pay_lsy_refund (status, next_query_time);
+
+-- 回填存量行。不回填的后果是**静默**的:`next_query_time <= now` 对 NULL 求值为 NULL
+-- (不是 true)⇒ 那些行永远不进对账集,而且不会有任何报错。
+UPDATE pay_lsy_refund
+   SET next_query_time = created_time
+ WHERE next_query_time IS NULL;
+
+-- ===== 核对(三句都跑一遍)=====
+--
+-- 1) 列存在且是 timestamptz —— 期望一行:next_query_time | timestamp with time zone
+-- SELECT column_name, data_type FROM information_schema.columns
+--  WHERE table_name = 'pay_lsy_refund' AND column_name = 'next_query_time';
+--
+-- 2) 不留 NULL —— 期望 **0**
+-- SELECT count(*) AS null_cursor_rows FROM pay_lsy_refund WHERE next_query_time IS NULL;
+--
+-- 3) 索引存在 —— 期望一行,且 indexdef 里是 (status, next_query_time)
+-- SELECT indexname, indexdef FROM pg_indexes
+--  WHERE tablename = 'pay_lsy_refund' AND indexname = 'idx_lsy_refund_reconcile';

+ 12 - 0
java/src/main/resources/db/migration/V1.8__create_pay_lsy_tables.sql

@@ -157,6 +157,14 @@ CREATE TABLE IF NOT EXISTS pay_lsy_refund (
     status              VARCHAR(32)  NOT NULL,
     upstream_result_code VARCHAR(32),
     upstream_result_msg  VARCHAR(255),
+    -- 对账游标。**为什么退款表需要它、订单表本来就有**
+    --   (pay_lsy_order.next_query_time,同名列同类型):订单侧有 expireStaleOrders 这个
+    --   排水阀,到点把非终态订单强制收敛 ⇒ 对账候选集**有界**,不存在饿死;
+    --   退款侧**没有**安全的本终态可落(写 FAIL 会释放 uk_lsy_refund_one_pending 与在途闸门
+    --   ⇒ 重复出金),候选集只进不出 ⇒ 只能靠**调度**排空。机制与证明见台账 §2.3「裁定:饿死缺口」。
+    --   ⚠ 必须 TIMESTAMP WITH TIME ZONE,与 pay_lsy_order.next_query_time 一致:
+    --     本仓有过「全库时间列漂移」的实测缺陷,这里用 without time zone 就是重开那个口子。
+    next_query_time     TIMESTAMP WITH TIME ZONE,
     -- 注意:上游退款接口里 pay_time 是"退款请求时间",原支付时间是 pay_trace_time
     refund_request_time VARCHAR(14),
     original_pay_time   VARCHAR(14),
@@ -234,6 +242,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_refund_trace_no      ON pay_lsy_refund
 --     CREATE TABLE IF NOT EXISTS 会让执行之后对文件的任何修改静默失效」,按内容找、别按行号。)
 CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_refund_refund_no ON pay_lsy_refund (refund_no);
 CREATE INDEX IF NOT EXISTS idx_lsy_refund_order ON pay_lsy_refund (order_id);
+-- 对账腿的范围扫描,形状对齐 idx_lsy_order_poll (status, pay_scene, next_query_time)。
+-- 谓词 `status IN (PENDING) AND next_query_time <= now ORDER BY next_query_time` 正好是三列前缀。
+CREATE INDEX IF NOT EXISTS idx_lsy_refund_reconcile ON pay_lsy_refund (status, next_query_time);
 -- ★ 部分唯一索引:同一订单最多只能有一笔"在途"退款(非终态)。
 --   这是防重复退款的**结构性**防线,不是尽力而为的应用层检查。
 --   为什么必须有:可退余额 remaining = total_amount - refunded_amount,而 refunded_amount
@@ -260,6 +271,7 @@ 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 '上游返回,原样落库(本侧不读)';
 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 '上游结果码';
 COMMENT ON COLUMN pay_lsy_refund.upstream_result_msg  IS '上游结果描述';
 COMMENT ON COLUMN pay_lsy_refund.refund_request_time  IS '退款请求时间(上游接口的 pay_time,yyyyMMddHHmmss)';

+ 102 - 6
java/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.java

@@ -19,6 +19,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.time.OffsetDateTime;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -29,6 +30,7 @@ import java.util.regex.Pattern;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
@@ -443,13 +445,22 @@ class LsySchedulerTest {
     }
 
     /**
-     * 扫描条件必须是「非终态 **且** 超过阈值」。
+     * 扫描条件 = 非终态 **且 游标到期**,并且**按游标升序**取。
      *
-     * <p>少了 `status IN PENDING` 这一半,这条腿会对**每一笔历史退款**问上游一遍;
-     * 少了时间阈值那一半,它会对刚发起的退款立刻问一次(那时上游必然回 PAY_IN_PROCESS)。
+     * <p>少了 `status IN PENDING` 这一半,这条腿会对**每一笔历史退款**问上游一遍。
+     *
+     * <p>★ 「游标到期」替代「`created_time` 超过阈值」是 2026-09-22 的裁定(台账 §2.3)。
+     * 旧谓词是**单调**的 —— 一旦为真永远为真 —— 而卡住的行根本不写库
+     * (`LsyRefundService.settleFromQuery` 的在途/未知码分支一次 mapper 调用都没有)
+     * ⇒ 元组不移位 ⇒ 每轮返回的是**同一批**,`LIMIT 100` 之外的行**永远轮不到**。
+     * 饿死因此是**稳态**而非概率事件。
+     *
+     * <p>★ `ORDER BY` 与谓词是**配套的、不可拆**:只换谓词不加排序,取到的仍是任意的一批;
+     * 只加排序不换谓词,谓词单调 ⇒ 「最老的一批」被反复取到,饿死反而变成**确定性的**。
+     * 两条断言因此写在同一个用例里 —— 它们描述的是同一个不变量。
      */
     @Test
-    void reconcilePendingRefunds_扫描条件为非终态且超过阈值() {
+    void reconcilePendingRefunds_扫描条件为非终态且游标到期_并按游标升序() {
         when(refundMapper.selectList(any())).thenReturn(List.of());
 
         scheduler().reconcilePendingRefunds();
@@ -461,8 +472,93 @@ class LsySchedulerTest {
 
         assertEquals(LsyRefundStatus.PENDING, allowedSourceStatuses(w),
                 "退款对账的扫描必须是 status IN LsyRefundStatus.PENDING,实测: " + where);
-        assertTrue(where.contains("created_time <="),
-                "必须带创建时间阈值,否则会对刚发起的退款立刻问上游,实测: " + where);
+        assertTrue(where.contains("next_query_time <="),
+                "必须按**会前进的游标**筛到期,实测: " + where);
+        assertFalse(where.contains("created_time"),
+                "不得再按 created_time 筛:单谓词=每轮取同一批=饿死,实测: " + where);
+        // 方向必须一起钉:`ORDER BY next_query_time` 单独成立时,DESC 也满足它 ——
+        // 而 DESC 恰好把机制**反向**(最老的一批永远排最前,饿死从"任意一批"升级成"确定的那批")。
+        assertTrue(where.contains("ORDER BY next_query_time ASC"),
+                "必须按游标**升序**取(升序 = 等得最久的先服务 = 轮转),实测: " + where);
+    }
+
+    /**
+     * 未收敛的行必须被推走 —— 这是消除饿死的**机制本体**。
+     *
+     * <p>【为什么必须推】`settleFromQuery` 返回 null 的行(上游回 `PAY_IN_PROCESS`、或回了
+     * 我们没见过的码)**一次库都不写**。对账腿若也不推它,它的 `next_query_time` 就永远停在
+     * 过去 ⇒ 每轮都排在 `ORDER BY next_query_time ASC` 的最前面 ⇒ 这样的行一旦多到 `LIMIT 100`,
+     * 后面的行**永远轮不到**。
+     *
+     * <p>【不变量】处理过的行,其新游标 `now + delay` **严格大于**所有「已到期且本轮未处理」的
+     * 行的游标 ⇒ 下一轮选中的 100 行与本轮选中的(仍留在 PENDING 的)**不相交** ⇒ 每行都在
+     * ⌈N/100⌉ 轮内必被问到。这条用例钉的就是那个严格大于。
+     */
+    @Test
+    void reconcilePendingRefunds_未收敛时必须把游标推到未来() {
+        LsyRefundEntity r = pendingRefund();
+        when(refundMapper.selectList(any())).thenReturn(List.of(r));
+        when(refundService.queryRefund(r)).thenReturn(resp("PAY_IN_PROCESS"));
+        when(refundService.settleFromQuery(eq(r), any())).thenReturn(null);   // 未流转
+
+        OffsetDateTime before = OffsetDateTime.now();
+        scheduler().reconcilePendingRefunds();
+
+        var patch = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        verify(refundMapper).update(patch.capture(), any());
+        OffsetDateTime next = patch.getValue().getNextQueryTime();
+        assertNotNull(next, "必须写 next_query_time —— 写成 null 等于把这一行踢出对账集");
+        assertTrue(next.isAfter(before.plusSeconds(props.getReconcileDelaySeconds() - 1)),
+                "必须推到 now + reconcileDelaySeconds 之后,实测: " + next);
+    }
+
+    /**
+     * 查询**抛异常**的行走的也是同一条路:必须推。
+     *
+     * <p>只对「未收敛」推是不够的。若某些行稳定地让客户端抛异常(参数被上游拒答、
+     * 特定 host 连不通等),它们的 `settleFromQuery` 根本不会被调用到,返回值那份判据
+     * **看不见它们** ⇒ 它们把 `LIMIT 100` 的头部占死,而「未收敛」那些行反而轮得到。
+     * 两种情形是同一个缺口的两副面孔,各需独立用例。
+     */
+    @Test
+    void reconcilePendingRefunds_查询抛异常时也必须把游标推到未来() {
+        LsyRefundEntity r = pendingRefund();
+        when(refundMapper.selectList(any())).thenReturn(List.of(r));
+        when(refundService.queryRefund(r)).thenThrow(new RuntimeException("网络超时"));
+
+        OffsetDateTime before = OffsetDateTime.now();
+        assertDoesNotThrow(() -> scheduler().reconcilePendingRefunds());
+
+        var patch = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
+        verify(refundMapper).update(patch.capture(), any());
+        OffsetDateTime next = patch.getValue().getNextQueryTime();
+        assertNotNull(next, "抛异常的行走的也是同一条路,同样不得留在原地");
+        assertTrue(next.isAfter(before.plusSeconds(props.getReconcileDelaySeconds() - 1)),
+                "实测: " + next);
+    }
+
+    /**
+     * 推游标必须是**条件更新**,守卫 `status IN PENDING` 写在 WHERE 里。
+     *
+     * <p>【为什么这条不能靠返回值】`update` 的返回值是测试自己 stub 的,它证明不了 WHERE 长什么样。
+     * 已收敛的行(本轮真的写成了 SUCCESS/FAIL)不该再被写游标 —— 不是因为它危险,
+     * 是因为写入本身没有意义,而本模块只允许「守卫在 WHERE 里」这一种条件更新风格
+     * (`applyPayResult` / `markExpired` / `applyCloseResult` 同形)。
+     */
+    @Test
+    void reconcilePendingRefunds_推游标带非终态守卫() {
+        LsyRefundEntity r = pendingRefund();
+        when(refundMapper.selectList(any())).thenReturn(List.of(r));
+        when(refundService.queryRefund(r)).thenReturn(resp("PAY_SUCCESS"));
+        when(refundService.settleFromQuery(eq(r), any())).thenReturn("SUCCESS");
+
+        scheduler().reconcilePendingRefunds();
+
+        var captor = org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(refundMapper, atLeastOnce()).update(any(), captor.capture());
+        assertEquals(LsyRefundStatus.PENDING, allowedSourceStatuses(captor.getValue()),
+                "推游标必须带 status IN PENDING 守卫,实测: " + captor.getValue().getSqlSegment());
+        verify(refundMapper, never()).updateById(any());
     }
 
     @Test

+ 10 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java

@@ -3,6 +3,7 @@ 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.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
 import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
@@ -31,11 +32,13 @@ class LsyRefundServiceTest {
     @Mock private LsyOrderService orderService;
     @Mock private LsyCallbackService callbackService;
     private LsyRefundService service;
+    /** 只为对账游标的初值(`reconcileDelaySeconds`)而需要;用默认值即可。 */
+    private final LeshouyingProperties props = new LeshouyingProperties();
 
     @BeforeEach
     void setUp() {
         service = new LsyRefundService(client, new LeshouyingResponseParser(), refundMapper,
-                orderMapper, orderService, callbackService);
+                orderMapper, orderService, callbackService, props);
     }
 
     private LsyOrderEntity order() {
@@ -178,6 +181,12 @@ class LsyRefundServiceTest {
             LsyRefundEntity e = inv.getArgument(0);
             assertEquals("CREATED", e.getStatus(),
                     "INSERT 那一刻必须是 CREATED(「上游可能还没收到」),不能是终态");
+            // ★ 台账 §2.3「裁定:饿死缺口」:对账扫描谓词是 `next_query_time <= now`。
+            //   SQL 里这个比较对 NULL 求值为 NULL(**不是** true)⇒ 漏写这一列的行永远不进
+            //   对账集,而且整个过程静默。本方法是 pay_lsy_refund 的**唯一** insert 点
+            //   (全仓 grep `refundMapper.insert` 只有一处),所以钉在这里就钉住了全部。
+            assertNotNull(e.getNextQueryTime(),
+                    "预插入必须写对账游标;写成 null 等于把这一行静默踢出对账集");
             e.setId(77L);   // 模拟 MyBatis-Plus 回填雪花 id:后面两次 update 都按它定位
             return 1;
         }).when(refundMapper).insert(any());