Explorar el Código

chore(乐收赢): 卡死退款单只读诊断 + 人工标记失败 SQL 交付件(待用户执行)

2026-10-06 11:15:34 两笔 /v3/refundorder 同步回 PAY_FAIL(上游可用余额不足),后端按
R11/R-1 有意不落终态 ⇒ 行停在 REFUNDING,占着 uk_lsy_refund_one_pending 与在途闸门
⇒ 订单一直「退款中」且不可再退。用户同日实测仍未转终态 ⇒ 对账腿没收敛。

- lsy-refund-stuck-diag.sql(只读):两行现状 + 全库非终态退款普查。判读法写在文件头
  —— 对账腿每轮无条件推 next_query_time 并同写 updated_time(pushNextReconcileTime),
  两列冻住与否即可判「腿有没有在碰它们」;且腿若问到 PAY_FAIL 早就自动落 FAIL 了。
- lsy-refund-manual-fail.sql:代码注释第③条出口「人工改库」。按 refund_trace_no(全局
  唯一)+ out_refund_no + status IN (CREATED,REFUNDING) 守卫精确命中两行,写
  status='FAIL'/finish_time/updated_time —— 字段集与 settleFromQuery → applyNotifyResult
  的 FAIL 终态一致。前置(上游确认没退成功,防双重出金)与副作用(绕过应用层:不发 SSE、
  不推下游退款回调)全部写在文件头。

两文件均待用户执行(执行读数只能来自执行者本人)。结构修复(权限化人工出口)另行拍板。
alphaH hace 1 semana
padre
commit
5a034f71de

+ 84 - 0
java/src/main/resources/db/lsy-refund-manual-fail.sql

@@ -0,0 +1,84 @@
+-- 【数据修复,不是 DDL】人工把两笔卡死的退款标记为失败、释放在途闸门(2026-10-06)
+-- 手工执行:psql -f 本文件,或用任意客户端逐条跑。执行由用户做,读数请回贴。
+--
+-- 【本脚本尚未执行;执行前先把下面「执行前提」读完】
+--
+-- ── 这是什么 ─────────────────────────────────────────────────────────
+-- 代码注释里写明的**第 ③ 条出口「人工改库」**:退款行停在 REFUNDING 时
+-- (对账腿不收敛、回调又不来),由人工核对上游后手动落 FAIL,释放
+-- uk_lsy_refund_one_pending 与在途闸门 ⇒ 该订单恢复可退款。
+--
+-- ── ★★ 执行前提(缺一不可)──────────────────────────────────────────
+-- 1) **必须先去上游(乐收赢/汇安融商户后台,或直接问上游)确认这两笔退款没有成功出金。**
+--    本脚本的全部依据只有:同步应答说 PAY_FAIL(余额不足)+ 对账腿一个多小时没收敛。
+--    而 R11/R-1 这套「不落终态」的设计前提恰恰是「上游可能误报」——如果钱其实退出去了,
+--    写 FAIL 会放闸 ⇒ 同一张单可以再退一次 ⇒ **双重出金**。这是本脚本唯一的风险面,
+--    也是「为什么必须先人工核对」的全部理由。核对不确定 ⇒ 不跑本脚本。
+-- 2) 若打算马上重退:先确认上游商户**可用余额已补**(否则重退会被同一理由再拒一次)。
+-- 3) 跑之前先在订单页/退款页刷新一次,记下这两笔当前状态(下面 Step 3/4 要对照)。
+--
+-- ── 已知副作用(本 SQL 绕过应用层,照实列)────────────────────────────
+-- 与正常终态通道(LsyRefundService.applyNotifyResult)相比,本 SQL **不做**两件事:
+--   a) 不发 SSE —— 前端订单实时刷新不会自动动;手动刷新照常看到「退款失败」;
+--   b) **不推下游退款回调** —— notify_status 留在 'PENDING',租户若配置了退款回调地址,
+--      这两笔**不会**收到「退款失败」回调。正常通道会推(pushRefundCallbackIfTerminal)。
+--      这两笔是 2026-10-06 11:15 的批量退款测试单;若确实需要补一条回调,
+--      先别跑本脚本,另行讨论。
+-- 此外不动任何业务字段:upstream_result_code / upstream_result_msg 保留上游原话
+-- ('PAY_FAIL' / 业务执行失败[…可用余额不足])。
+--
+-- ── 判据(三格取交集,逐格说清)──────────────────────────────────────
+--   refund_trace_no IN (...):**全局唯一**(uk_lsy_refund_trace_no)⇒ 单格即可钉死一行。
+--     两个值取自 2026-10-06 11:15:34 线上日志原文。
+--   out_refund_no IN (...)  :人眼可读的一致性核对(租户级唯一)。
+--   status IN ('CREATED','REFUNDING'):守卫。已终态的行**一个字节都不动**
+--     (对账腿/回调若抢先收敛,这里命中 0 行)⇒ 本脚本幂等,重跑是空操作。
+--
+-- ── 写下去的值与谁对齐 ───────────────────────────────────────────────
+--   status='FAIL' + finish_time=now() + updated_time=now():
+--   与 settleFromQuery → applyNotifyResult(..., "FAIL") 在终态流转时写的字段集一致
+--   (applyNotifyResult 写 status / upstream_result_code / upstream_result_msg /
+--   finish_time / updated_time;这里不重写 code/msg,它们已是上游同步应答原文)。
+--
+-- ── 期望读数 ─────────────────────────────────────────────────────────
+--   Step 1 输出 2 行;UPDATE 报 **2**;Step 3 两行 status='FAIL'、finish_time 非空;
+--   Step 4 的 pending_refunds = 0。
+--   ⚠ 若 UPDATE 报 0 或 1 行:**停下、别重跑**,把四段输出一起回贴 ——
+--     说明行已被对账/回调抢先收敛,或行数不是预期的两笔,任一情况都要先核对再定。
+
+-- Step 1(只读):先看命中哪些行,确认与上面两个单号一致
+SELECT out_trade_no, out_refund_no, refund_trace_no, status,
+       next_query_time, updated_time, finish_time
+  FROM pay_lsy_refund
+ WHERE refund_trace_no IN ('302d459a176e4f9f91de51c2111d1d3b', 'ac23251a4832497b9a7692e6ac53d97b')
+   AND out_refund_no  IN ('RF2107308753332903936', 'RF2107308757233606656')
+   AND status IN ('CREATED', 'REFUNDING');
+
+-- Step 2:标记失败(期望 2 行)
+UPDATE pay_lsy_refund
+   SET status       = 'FAIL',
+       finish_time  = now(),
+       updated_time = now()
+ WHERE refund_trace_no IN ('302d459a176e4f9f91de51c2111d1d3b', 'ac23251a4832497b9a7692e6ac53d97b')
+   AND out_refund_no  IN ('RF2107308753332903936', 'RF2107308757233606656')
+   AND status IN ('CREATED', 'REFUNDING');
+
+-- Step 3(只读):核对 —— 两行应为 FAIL、finish_time 非空
+SELECT out_refund_no, status, upstream_result_code, upstream_result_msg,
+       finish_time, updated_time
+  FROM pay_lsy_refund
+ WHERE out_refund_no IN ('RF2107308753332903936', 'RF2107308757233606656')
+ ORDER BY created_time;
+
+-- Step 4(只读):订单侧应恢复可退(pending_refunds 期望 0)
+SELECT o.out_trade_no, o.status AS order_status, o.refunded_amount,
+       (SELECT count(*) FROM pay_lsy_refund r
+         WHERE r.order_id = o.id AND r.status IN ('CREATED', 'REFUNDING')) AS pending_refunds
+  FROM pay_lsy_order o
+ WHERE o.id IN (SELECT order_id FROM pay_lsy_refund
+                 WHERE out_refund_no IN ('RF2107308753332903936', 'RF2107308757233606656'));
+
+-- ── 回滚(正常情况下不要用)──────────────────────────────────────────
+-- 只有「执行后才发现上游其实还在处理/其实退成功了」才谈回滚,且必须先人工把事实核清。
+-- 形式上就是把 status 从 'FAIL' 写回,但**不要**照抄一句简单的 UPDATE ——
+-- 那时状态已是终态、回调/对账都已被关在门外,回写要连同上游事实一起重新裁定。

+ 53 - 0
java/src/main/resources/db/lsy-refund-stuck-diag.sql

@@ -0,0 +1,53 @@
+-- 【只读诊断,不改任何数据】卡在「退款中」的两笔退款单:现状 + 对账腿活跃度(2026-10-06)
+-- 手工执行:psql -f 本文件,或用任意客户端逐条跑。**无副作用**,可反复跑,读数请原样回贴。
+--
+-- ── 背景(这两行是怎么卡的)──────────────────────────────────────────
+-- 2026-10-06 11:15:34 两笔 /v3/refundorder 同步返回 PAY_FAIL:
+--   outRefundNo=RF2107308753332903936, refundTraceNo=302d459a176e4f9f91de51c2111d1d3b
+--   outRefundNo=RF2107308757233606656, refundTraceNo=ac23251a4832497b9a7692e6ac53d97b
+--   return_msg=业务执行失败[6666000245376951_F53933370可用余额不足]
+-- 后端按 R11/R-1 **有意不落终态**(上游可能误报;同步 PAY_FAIL 落 FAIL 会释放闸门 ⇒
+-- 重复出金)⇒ 行停在 REFUNDING,同时占着 uk_lsy_refund_one_pending 与在途闸门
+-- ⇒ 订单显示「退款中」且不可再退。设计上的出口有三条:① 迟到回调 ② 对账腿 ③ 人工改库。
+-- 用户同日实测仍为「退款中」⇒ ② 没有收敛。
+--
+-- ── 这份诊断回答什么:对账腿到底有没有在碰这两行 ──────────────────────
+-- 机制(LsyReconcileScheduler.reconcilePendingRefunds,每 5 分钟一轮):
+-- 扫 status IN ('CREATED','REFUNDING') AND next_query_time <= now(),升序 LIMIT 100;
+-- 每轮对每行**无条件**调 pushNextReconcileTime:把 next_query_time 推到 now()+60s,
+-- 并同写 updated_time(守卫 WHERE status IN PENDING)。
+-- ⇒ 若腿在跑且这两行在扫描集里,两列应当「新鲜」:
+--     next_query_time ∈ (db_now-5min, db_now+60s]、updated_time ∈ (db_now-5min, db_now]。
+-- ⇒ 若两列都冻在 11:15~11:16(created_time 附近):腿没有碰这两行
+--   (腿没装配/没跑,或行不在扫描集 —— 后者包含 next_query_time IS NULL 的**静默漏扫**,
+--    见 LsyRefundEntity#nextQueryTime 的注释)。
+--
+-- 另一条推断(不依赖本文件也成立):腿若问到过 PAY_FAIL,行**早就自动落 FAIL 了**
+-- (LsyRefundService.settleFromQuery 的 isPayFail 分支写终态)。还停在 REFUNDING
+-- ⇒ 只可能是:上游查询一直回「在途」(settleFromQuery 对 PAY_IN_PROCESS **静默返回 null**,
+-- 连日志都没有 —— 四条路径里唯一无痕的),或者腿根本没在跑。
+-- 若第一段输出显示两列新鲜 ⇒ 基本坐实「上游查询一直回在途」:这行**永远等不来自动收敛**。
+
+SELECT now() AS db_now,
+       out_refund_no,
+       refund_trace_no,
+       status,
+       upstream_result_code,
+       upstream_result_msg,
+       next_query_time,
+       created_time,
+       updated_time,
+       finish_time
+  FROM pay_lsy_refund
+ WHERE out_refund_no IN ('RF2107308753332903936', 'RF2107308757233606656')
+ ORDER BY created_time;
+
+-- 上下文:全库当前还有多少非终态退款行(这两笔应在其中;若别处也积了一批,
+-- 「权限化人工出口」的需求就更硬 —— 每一行都占着对应订单的在途闸门)
+SELECT status,
+       count(*)          AS rows,
+       min(created_time) AS oldest_created,
+       max(updated_time) AS newest_touch
+  FROM pay_lsy_refund
+ WHERE status IN ('CREATED', 'REFUNDING')
+ GROUP BY status;