From e3f412c5474311a471345ea508db4875c9ec648e Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 17 九月 2026 15:47:59 +0800
Subject: [PATCH] feat(payment): 支付按支付尝试收敛,取消与放弃以关单回执为准

---
 talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java |  343 ++++++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 304 insertions(+), 39 deletions(-)

diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
index 681062f..516d400 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingPaymentConfirmationTests.java
@@ -5,11 +5,14 @@
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import java.util.List;
 import java.util.Map;
 
+import com.talentcard.server.business.payment.PaymentReconciliationService.PendingPaymentConfirmation;
 import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
 import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseCommand;
 import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
@@ -61,7 +64,7 @@
     void prepareData() {
         suffix = String.valueOf(System.nanoTime());
         // 共享测试库里可能留着历史遗留的挂起流水,只有本用例自己创建的才能落进对账窗口。
-        jdbc.update("update payment_transaction set created_at = now() where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')");
+        jdbc.update("update payment_transaction set created_at = now(), last_reconciled_at = null where status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')");
         jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                 "pending-payment-openid-" + suffix, "Pending payment user", "13900000003");
         talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
@@ -112,27 +115,6 @@
     }
 
     @Test
-    void cancellingIsRefusedAndRecordedWhenClosingRevealsTheTradeWasAlreadyPaid() {
-        String orderNo = pendingOrder();
-        String paymentNo = pendingPayment(orderNo, 2000L);
-        // 查单说「交易不存在」,关单却回「原订单已为终态」且原交易已成功:钱可能真的在,不能当成没收过款。
-        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
-                "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)));
-        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
-                "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
-                "org_trans_stat", "S", "huifu_id", huifuId)));
-
-        // 查单拿不到金额,无法把订单落成已支付,所以不能谎称已支付;但订单也绝不能被取消。
-        assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
-                .isInstanceOf(BusinessException.class)
-                .hasMessageContaining("确认中");
-
-        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
-        assertThat(count("select count(*) from payment_transaction where payment_no = ? and status = 'PREPAY_CREATED'", paymentNo)).isEqualTo(1);
-        assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L);
-    }
-
-    @Test
     void cancellingProceedsButKeepsThePrepayWatchableWhenTheProviderRefusesToCloseIt() {
         String orderNo = pendingOrder();
         String paymentNo = pendingPayment(orderNo, 2000L);
@@ -151,32 +133,107 @@
     }
 
     @Test
-    void cancellingIsRefusedWhileTheProviderIsStillClosingThePrepay() {
+    void cancellingClosesThePrepayEvenWhenTheQueryOnlySaysItIsProcessing() {
         String orderNo = pendingOrder();
-        pendingPayment(orderNo, 2000L);
+        String paymentNo = pendingPayment(orderNo, 2000L);
+        // 查单只答「处理中」:旧实现把它当成关单的前置门,于是关单一次都不会被调用。
+        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "trans_amt", "20.00", "huifu_id", huifuId)));
+        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "F", "resp_code", "20000001", "resp_desc", "不允许关闭一分钟以内的订单", "huifu_id", huifuId)));
+
+        Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
+
+        verify(paymentGateway, times(1)).closeMiniProgramPayment(any());
+        assertThat(((Number) result.get("orderStatus")).intValue())
+                .as("关单回执是明确拒绝,不是「还在处理中」,因此允许取消并继续盯防")
+                .isEqualTo(2);
+        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+                .isEqualTo("CANCEL_UNCONFIRMED");
+    }
+
+    @Test
+    void closingReportsTheOriginalTradeSucceededAndTheQueryThenLandsTheOrderAsPaid() {
+        String orderNo = pendingOrder();
+        String paymentNo = pendingPayment(orderNo, 2000L);
+        // 第一次查单说交易不存在(所以会去关单),关单回执说原交易已经成功,再查一次拿到了金额。
+        when(paymentGateway.queryMiniProgramPayment(any()))
+                .thenReturn(providerResponse(Map.of("resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)))
+                .thenReturn(providerResponse(Map.of(
+                        "trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00",
+                        "huifu_id", huifuId, "org_hf_seq_id", "HF-CLOSE-PAID-" + suffix)));
+        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
+                "org_trans_stat", "S", "huifu_id", huifuId)));
+
+        assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("已支付");
+
+        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+                .as("关单回执说原交易已成功,就必须落成已支付,而不是把一笔真钱留在待支付里")
+                .isEqualTo(1);
+        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+                .isEqualTo("SUCCEEDED");
+    }
+
+    @Test
+    void cancellingWaitsForTheCloseConclusionWhenClosingRevealsTheTradeWasAlreadyPaid() {
+        String orderNo = pendingOrder();
+        String paymentNo = pendingPayment(orderNo, 2000L);
+        // 查单说「交易不存在」,关单却回「原订单已为终态」且原交易已成功:钱可能真的在,不能当成没收过款。
+        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)));
+        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
+                "org_trans_stat", "S", "huifu_id", huifuId)));
+
+        // 查单拿不到金额,无法把订单落成已支付,所以不能谎称已支付;但订单也绝不能被取消。
+        Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
+
+        assertThat(result)
+                .as("关单没有结论时,取消的意图被受理但要等结论,而不是给一个永久报错")
+                .containsEntry("cancellationPending", true);
+        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
+        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+                .as("关单中:既不能当已关闭(通道还可能收钱),也不该是终点")
+                .isEqualTo("CLOSING");
+        assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo))
+                .as("关单中期间只能复用,不允许新建第二张可被支付的预支付单")
+                .isEqualTo(1);
+        assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L);
+    }
+
+    @Test
+    void cancellingWaitsForTheCloseConclusionWhileTheProviderIsStillClosingThePrepay() {
+        String orderNo = pendingOrder();
+        String paymentNo = pendingPayment(orderNo, 2000L);
         when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                 "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
         when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
                 "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId)));
 
-        assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
-                .isInstanceOf(BusinessException.class)
-                .hasMessageContaining("确认中");
+        Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
 
+        assertThat(result).containsEntry("cancellationPending", true);
         assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
+        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+                .isEqualTo("CLOSING");
+        assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo))
+                .as("关单重试次数要落库,否则重试上限永远到不了")
+                .isEqualTo(1);
     }
 
     @Test
-    void cancellingIsRefusedWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
+    void cancellingWaitsForTheProviderWhenItCannotSayWhetherTheMoneyWasCollected() {
         String orderNo = pendingOrder();
         String paymentNo = pendingPayment(orderNo, 2000L);
         when(paymentGateway.queryMiniProgramPayment(any()))
                 .thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null));
 
-        assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
-                .isInstanceOf(BusinessException.class)
-                .hasMessageContaining("确认中");
+        Map<String, Object> result = pendingOrderService.cancel(orderNo, talentId);
 
+        assertThat(result).containsEntry("cancellationPending", true);
         assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
         assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
                 .as("an unanswerable payment question must not destroy the only record of the money")
@@ -202,18 +259,200 @@
     }
 
     @Test
-    void retryingAPaymentIsRefusedWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
+    void retryingAPaymentReportsWaitingWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
         String orderNo = pendingOrder();
         pendingPayment(orderNo, 2000L);
         when(paymentGateway.queryMiniProgramPayment(any()))
                 .thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null));
 
-        assertThatThrownBy(() -> paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix))
-                .isInstanceOf(BusinessException.class)
-                .hasMessageContaining("确认中");
+        Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix);
 
+        assertThat(result)
+                .as("问不出来的不确定只属于这一次支付尝试,不该把用户堵在一句报错上")
+                .containsEntry("paymentStatus", "PAYMENT_PROCESSING");
         verify(paymentGateway, never()).createMiniProgramPayment(any());
         assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isZero();
+    }
+
+    @Test
+    void askingToPayAgainReusesTheStoredPrepayWithoutAskingTheProvider() {
+        String orderNo = pendingOrder();
+        String paymentNo = pendingPayment(orderNo, 2000L, "PREPAY_CREATED", "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}");
+
+        Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "reuse-key-" + suffix);
+
+        assertThat(result)
+                .as("本地已经持有可用的支付参数时直接把这张预支付单交回去——查通道只会得到「处理中」这种没有结论的答复")
+                .containsEntry("paymentStatus", "PAYMENT_PENDING")
+                .containsEntry("paymentNo", paymentNo);
+        assertThat(result.get("paymentParams")).isNotNull();
+        verify(paymentGateway, never()).queryMiniProgramPayment(any());
+        verify(paymentGateway, never()).createMiniProgramPayment(any());
+        assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo))
+                .as("复用不新建支付流水")
+                .isEqualTo(1);
+    }
+
+    @Test
+    void askingToPayAgainIsRefusedWhenAnUndecidedPrepayCannotBeReused() {
+        String orderNo = pendingOrder();
+        // 先取消,通道答「处理中」→ 关单没有结论,这次尝试停在 CLOSING 且订单仍待支付;
+        // 它没有留下可复用的支付参数,因此既不能复用、也不许新建。
+        String paymentNo = pendingPayment(orderNo, 2000L);
+        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
+        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId)));
+        pendingOrderService.cancel(orderNo, talentId);
+        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("CLOSING");
+        clearInvocations(paymentGateway);
+
+        Map<String, Object> result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "blocked-key-" + suffix);
+
+        assertThat(result).containsEntry("paymentStatus", "PAYMENT_PROCESSING");
+        verify(paymentGateway, never()).createMiniProgramPayment(any());
+        assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo))
+                .as("上一张预支付单没有被证明不可能再被支付之前,不允许给同一笔订单再建一张")
+                .isEqualTo(1);
+        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+                .isEqualTo("CLOSING");
+    }
+
+    @Test
+    void anExpiredOrderCannotBePaidEvenWhenAPrepayIsStillStoredLocally() {
+        String orderNo = pendingOrder();
+        pendingPayment(orderNo, 2000L, "PREPAY_CREATED", "{\"appId\":\"wx-test\",\"timeStamp\":\"1\"}");
+        expireNow(orderNo);
+        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)));
+
+        assertThatThrownBy(() -> paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "expired-key-" + suffix))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("超时");
+
+        verify(paymentGateway, never()).createMiniProgramPayment(any());
+    }
+
+    @Test
+    void abandoningAPaymentOnlyClosesItAndNeverJudgesWhetherMoneyArrived() {
+        String orderNo = pendingOrder();
+        String paymentNo = pendingPayment(orderNo, 2000L);
+        // 客户端说「我放弃了」,而关单回执说原交易已经成功——即使如此也不能据客户端的话去查单或结算。
+        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "F", "resp_code", "23000000", "resp_desc", "原订单已为终态,请发起查询交易获取",
+                "org_trans_stat", "S", "huifu_id", huifuId)));
+
+        PendingPaymentConfirmation confirmation = reconciliationService.abandonPendingPayments(orderNo, paymentNo);
+
+        verify(paymentGateway, never()).queryMiniProgramPayment(any());
+        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+                .as("放弃只触发关单:关单没拿到「关掉」的结论就停在关单中,绝不做任何收款判定")
+                .isEqualTo("CLOSING");
+        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+                .as("放弃支付不是取消订单")
+                .isZero();
+        assertThat(confirmation.collected()).isFalse();
+        assertThat(openIssueCount("PAYMENT_CLOSE_CONFLICT", paymentNo)).isEqualTo(1L);
+    }
+
+    @Test
+    void abandoningAPaymentClosesThePrepaySoTheOrderCannotBePaidTwice() {
+        String orderNo = pendingOrder();
+        String paymentNo = pendingPayment(orderNo, 2000L);
+        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "org_trans_stat", "P", "huifu_id", huifuId)));
+
+        reconciliationService.abandonPendingPayments(orderNo, paymentNo);
+
+        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+                .isEqualTo("CLOSED");
+    }
+
+    @Test
+    void thePrepayCloseStopsRetryingAtTheCapAndEndsTheOrderWithAWatcher() {
+        String orderNo = pendingOrder();
+        String paymentNo = pendingPayment(orderNo, 2000L);
+        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "I", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId)));
+        // 通道一直答「还在处理中」:关单永远没有结论。
+        when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "trans_stat", "P", "resp_code", "00000100", "resp_desc", "交易处理中", "huifu_id", huifuId)));
+
+        assertThat(pendingOrderService.cancel(orderNo, talentId)).containsEntry("cancellationPending", true);
+        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+                .isEqualTo("CLOSING");
+
+        // 快车道每分钟一轮。每轮都把行推回窗口内(共享库里的残留行不影响本用例:断言只按自己的流水号过滤)。
+        for (int round = 0; round < 3; round++) reconcileOnce(paymentNo);
+
+        assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo))
+                .as("关单重试有明确上限")
+                .isEqualTo(3);
+        assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+                .as("到上限后落成「取消未确认」,预支付单继续被盯着")
+                .isEqualTo("CANCEL_UNCONFIRMED");
+        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+                .as("关单中不能永远挂着:到上限时订单落成已取消,不留一笔既付不了也退不出的僵尸订单")
+                .isEqualTo(2);
+        assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo))
+                .as("放弃关单要留下问题记录给人工")
+                .isEqualTo(1L);
+
+        // 到上限后不再重试:再跑一轮,关单重试计数不再增长。
+        reconcileOnce(paymentNo);
+        assertThat(jdbc.queryForObject("select close_attempts from payment_transaction where payment_no = ?", Integer.class, paymentNo))
+                .isEqualTo(3);
+    }
+
+    @Test
+    void anExpiredOrderWithAClosingOrUnconfirmedAttemptIsNotCancelledByTheDeadline() {
+        for (String status : List.of("CLOSING", "CANCEL_UNCONFIRMED")) {
+            String orderNo = pendingOrder();
+            String paymentNo = pendingPayment(orderNo, 2000L, status);
+            expireNow(orderNo);
+
+            pendingOrderService.expireDueOrders(talentId);
+            assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+                    .as("%s 的预支付单仍可能被支付,自动取消会把已收下的钱变成没有出口的悬账", status)
+                    .isZero();
+
+            pendingOrderService.detail(orderNo, talentId);
+            assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
+                    .as("单个订单的到期路径同样不能取消它(%s)", status)
+                    .isZero();
+            assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
+                    .isEqualTo(status);
+        }
+    }
+
+    @Test
+    void theReconciliationRotatesInsteadOfAlwaysTakingTheOldestHundredUndecidedPayments() {
+        String backlogOrderNo = pendingOrder();
+        Long backlogOrderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, backlogOrderNo);
+        // 100 条刚被处理过的历史未决流水:它们都在退避窗口内,不该再被选中。
+        for (int index = 0; index < 100; index++) {
+            jdbc.update("""
+                    insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
+                                                    provider_huifu_id, amount, status, idempotency_key, created_at, last_reconciled_at)
+                    values (?, 'HUIFU', ?, ?, ?, ?, ?, 2000, 'PREPAY_CREATED', ?, now() - interval 30 minute, now())
+                    """, "P-BACKLOG-" + suffix + "-" + index, backlogOrderId, backlogOrderNo, talentId, merchantId, huifuId,
+                    "backlog-" + suffix + "-" + index);
+        }
+        assertThat(count("select count(*) from payment_transaction where order_no = ?", backlogOrderNo)).isEqualTo(100);
+
+        String orderNo = pendingOrder();
+        String paymentNo = pendingPayment(orderNo, 2000L);
+        jdbc.update("update payment_transaction set created_at = now() - interval 5 minute where payment_no = ?", paymentNo);
+        when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
+                "resp_code", "23000001", "resp_desc", "交易不存在", "huifu_id", huifuId)));
+
+        reconciliationService.reconcile(5);
+
+        assertThat(openIssueCount("PAYMENT_PENDING_TIMEOUT", paymentNo))
+                .as("刚出现的未决流水不能被历史积压挤出处理窗口——它正是死锁唯一的自愈通道")
+                .isEqualTo(1L);
+        assertThat(count("select count(*) from payment_transaction where payment_no = ? and last_reconciled_at is not null", paymentNo))
+                .isEqualTo(1);
     }
 
     @Test
@@ -233,8 +472,9 @@
     /** A pending order bound to a collecting sub-merchant, created directly so the payment seam is the only subject. */
     @Test
     void anExpiredOrderWithAnUndecidedPaymentIsNotCancelledByTheDeadlineSweep() {
-        String orderNo = expiredOrder();
+        String orderNo = pendingOrder();
         String paymentNo = pendingPayment(orderNo, 2000L);
+        expireNow(orderNo);
 
         pendingOrderService.expireDueOrders(talentId);
 
@@ -338,16 +578,41 @@
     }
 
     private String pendingPayment(String orderNo, long amount, String status) {
+        return pendingPayment(orderNo, amount, status, null);
+    }
+
+    private String pendingPayment(String orderNo, long amount, String status, String clientPaymentPayload) {
         String paymentNo = "P-PENDING-" + suffix + "-" + orderSequence;
         Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
         jdbc.update("""
                 insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id,
-                                                provider_huifu_id, amount, status, idempotency_key, created_at)
-                values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now())
-                """, paymentNo, orderId, orderNo, talentId, merchantId, huifuId, amount, status, "pending-payment-" + paymentNo);
+                                                provider_huifu_id, amount, status, idempotency_key, created_at,
+                                                client_payment_payload)
+                values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now(), ?)
+                """, paymentNo, orderId, orderNo, talentId, merchantId, huifuId, amount, status,
+                "pending-payment-" + paymentNo, clientPaymentPayload);
         return paymentNo;
     }
 
+    /** 把这一行推回对账窗口再跑一轮;断言只按本用例自己的流水号过滤,共享库里的残留行不影响结论。 */
+    private void reconcileOnce(String paymentNo) {
+        jdbc.update("""
+                update payment_transaction set created_at = now() - interval 5 minute, last_reconciled_at = null
+                where payment_no = ?
+                """, paymentNo);
+        reconciliationService.reconcile(5);
+    }
+
+    /**
+     * 让订单现在就算过期。
+     *
+     * <p>必须建完订单与支付尝试之后再推时间:到期清理任务({@code PendingOrderExpiryScheduler},测试里
+     * 也开着)会在「订单已过期、但还没有任何支付流水」的那一瞬间把它当成普通超时订单取消掉。
+     */
+    private void expireNow(String orderNo) {
+        jdbc.update("update benefit_order set expires_at = now() - interval 1 minute where order_no = ?", orderNo);
+    }
+
     private Map<String, Object> providerResponse(Map<String, Object> data) {
         return Map.of("data", data);
     }

--
Gitblit v1.7.1