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