package com.talentcard.server.business.payment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
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;
import com.talentcard.server.business.portal.PendingOrderPortalService;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
* Seam: the local state of a pending order can be behind the provider, because a payment
* notification that never arrived leaves the order looking unpaid while the money was collected.
*
*
Cancelling such an order must therefore ask the provider first. A payment the provider
* confirms settles the order and blocks the cancellation; a provider that says the money may still
* be moving blocks it too, because a question about money is never answered by guessing.
*/
@SpringBootTest
@ActiveProfiles("test")
class PendingPaymentConfirmationTests {
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PendingOrderPortalService pendingOrderService;
@Autowired
private PaymentReconciliationService reconciliationService;
@Autowired
private HuifuPaymentService paymentService;
@MockitoBean
private HuifuMiniProgramPaymentGateway paymentGateway;
private Long talentId;
private Long merchantId;
private String huifuId;
private String suffix;
private long orderSequence;
@BeforeEach
void prepareData() {
suffix = String.valueOf(System.nanoTime());
// 共享测试库里可能留着历史遗留的挂起流水,只有本用例自己创建的才能落进对账窗口。
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);
jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)",
"Pending payment merchant " + suffix, "08120000002", "Test address");
merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8));
orderSequence = 0;
}
@Test
void cancellingALocallyPendingOrderTheProviderCollectedIsRefusedAndTheOrderIsMarkedPaid() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00",
"huifu_id", huifuId, "org_hf_seq_id", "HF-CANCEL-" + suffix)));
assertThatThrownBy(() -> pendingOrderService.cancel(orderNo, talentId))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("已支付");
assertThat(jdbc.queryForMap("select order_status, verify_status from benefit_order where order_no = ?", orderNo))
.containsEntry("order_status", 1)
.containsEntry("verify_status", 1);
assertThat(jdbc.queryForMap("select status, paid_at from payment_transaction where payment_no = ?", paymentNo))
.containsEntry("status", "SUCCEEDED");
assertThat(count("select count(*) from payment_transaction where payment_no = ? and paid_at is not null", paymentNo)).isEqualTo(1);
}
@Test
void cancellingAnOrderTheProviderHasNotCollectedAsksTheProviderAndClosesIt() {
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", "S", "resp_code", "00000000", "resp_desc", "交易成功", "org_trans_stat", "P")));
Map result = pendingOrderService.cancel(orderNo, talentId);
assertThat(((Number) result.get("orderStatus")).intValue()).isEqualTo(2);
verify(paymentGateway).queryMiniProgramPayment(any());
ArgumentCaptor close = ArgumentCaptor.forClass(HuifuPaymentCloseCommand.class);
verify(paymentGateway).closeMiniProgramPayment(close.capture());
assertThat(close.getValue().paymentNo()).isEqualTo(paymentNo);
assertThat(count("select count(*) from payment_transaction where payment_no = ? and status = 'CLOSED'", paymentNo)).isEqualTo(1);
}
@Test
void cancellingProceedsButKeepsThePrepayWatchableWhenTheProviderRefusesToCloseIt() {
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", "F", "resp_code", "20000001", "resp_desc", "不允许关闭一分钟以内的订单", "huifu_id", huifuId)));
pendingOrderService.cancel(orderNo, talentId);
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(2);
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
.as("关不掉的预支付单还可能被支付,本地不能记成已关闭,要留给对账任务继续盯着")
.isEqualTo("CANCEL_UNCONFIRMED");
assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)).isEqualTo(1L);
}
@Test
void cancellingClosesThePrepayEvenWhenTheQueryOnlySaysItIsProcessing() {
String orderNo = pendingOrder();
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 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 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)));
Map 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 cancellingWaitsForTheProviderWhenItCannotSayWhetherTheMoneyWasCollected() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any()))
.thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null));
Map 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")
.isEqualTo("PREPAY_CREATED");
assertThat(openIssueCount("PAYMENT_QUERY_FAILED", paymentNo)).isEqualTo(1L);
}
@Test
void retryingAPaymentTheProviderCollectedReportsItAsPaidInsteadOfAskingTheUserToPayAgain() {
String orderNo = pendingOrder();
pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00",
"huifu_id", huifuId, "org_hf_seq_id", "HF-RETRY-" + suffix)));
when(paymentGateway.createMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "P", "resp_code", "00000000", "pay_info", "{\"appId\":\"wx-test\"}")));
Map result = paymentService.beginMiniProgramPayment(orderNo, talentId, 1, "retry-key-" + suffix);
assertThat(result).containsEntry("paymentStatus", "PAID");
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(1);
verify(paymentGateway, never()).createMiniProgramPayment(any());
}
@Test
void retryingAPaymentReportsWaitingWhenTheProviderCannotSayWhetherTheMoneyWasCollected() {
String orderNo = pendingOrder();
pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any()))
.thenThrow(new HuifuPaymentGatewayException("Huifu payment query failed", true, null));
Map 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 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 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
void askingForThePaymentStatusConfirmsWithTheProviderAndReportsTheCollectedPayment() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00",
"huifu_id", huifuId, "org_hf_seq_id", "HF-STATUS-" + suffix)));
Map result = paymentService.paymentStatus(orderNo, talentId, paymentNo);
assertThat(result).containsEntry("paymentStatus", "PAID");
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(1);
}
/** A pending order bound to a collecting sub-merchant, created directly so the payment seam is the only subject. */
@Test
void anExpiredOrderWithAnUndecidedPaymentIsNotCancelledByTheDeadlineSweep() {
String orderNo = pendingOrder();
String paymentNo = pendingPayment(orderNo, 2000L);
expireNow(orderNo);
pendingOrderService.expireDueOrders(talentId);
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
.as("到期不等于没收到钱:未定局的支付尝试只能交给通道确认后再处理")
.isZero();
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
.isEqualTo("PREPAY_CREATED");
}
@Test
void anExpiredOrderWhosePaymentNeverSucceededIsCancelledByTheDeadlineSweep() {
String orderNo = expiredOrder();
pendingPayment(orderNo, 2000L, "CREATE_FAILED");
pendingOrderService.expireDueOrders(talentId);
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(2);
}
@Test
void anExpiredOrderWithoutAnyPaymentIsCancelledByTheDeadlineSweep() {
String orderNo = expiredOrder();
pendingOrderService.expireDueOrders(talentId);
assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo)).isEqualTo(2);
}
@Test
void theReconciliationClosesAPrepayThatCouldNotBeClosedWhenTheOrderWasCancelled() {
String paymentNo = cancelledOrderWithUnconfirmedClose();
when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "huifu_id", huifuId)));
reconciliationService.reconcile(5);
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
.as("过了关单限制之后必须把预支付单关掉,否则用户手上那张单一直能付")
.isEqualTo("CLOSED");
assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)).isZero();
}
@Test
void theReconciliationNeverClosesAPrepayTheProviderAlreadyCollected() {
String paymentNo = cancelledOrderWithUnconfirmedClose();
clearInvocations(paymentGateway);
when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of(
"trans_stat", "S", "resp_code", "00000000", "trans_amt", "20.00", "huifu_id", huifuId,
"org_hf_seq_id", "HF-LATE-" + suffix)));
reconciliationService.reconcile(5);
verify(paymentGateway, never()).closeMiniProgramPayment(any());
assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo))
.as("取消之后才收到的钱要留在账目上让人处理,而不是当成没收过")
.isEqualTo(1L);
assertThat(openIssueCount("PAYMENT_CLOSE_UNCONFIRMED", paymentNo)).isZero();
}
/** 订单刚创建就取消:通道不允许关 1 分钟以内的订单,取消成立,但预支付单留着等下一轮关。 */
private String cancelledOrderWithUnconfirmedClose() {
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", "F", "resp_code", "20000001", "resp_desc", "不允许关闭一分钟以内的订单", "huifu_id", huifuId)));
pendingOrderService.cancel(orderNo, talentId);
assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo))
.isEqualTo("CANCEL_UNCONFIRMED");
// 对账只处理挂起超过 N 分钟的流水,把它推到窗口内。
jdbc.update("update payment_transaction set created_at = now() - interval 10 minute where payment_no = ?", paymentNo);
return paymentNo;
}
private String pendingOrder() {
return pendingOrder("date_add(now(), interval 30 minute)");
}
/** The same order, already past its payment deadline. */
private String expiredOrder() {
return pendingOrder("date_sub(now(), interval 1 minute)");
}
private String pendingOrder(String expiresAtSql) {
String orderNo = "PENDING-" + suffix + "-" + (++orderSequence);
jdbc.update("""
insert into benefit_order(order_no, talent_id, merchant_id, order_type, order_amount, base_pay_amount,
pay_amount, order_status, verify_status, expires_at, price_version, collect_huifu_id,
product_name_snapshot)
values (?, ?, ?, 'STORE_CONSUME', ?, ?, ?, 0, 0, %s, 1, ?, ?)
""".formatted(expiresAtSql), orderNo, talentId, merchantId, 2000L, 2000L, 2000L, huifuId, "待支付测试订单");
return orderNo;
}
private String pendingPayment(String orderNo, long amount) {
return pendingPayment(orderNo, amount, "PREPAY_CREATED");
}
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,
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);
}
/**
* 让订单现在就算过期。
*
* 必须建完订单与支付尝试之后再推时间:到期清理任务({@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 providerResponse(Map data) {
return Map.of("data", data);
}
private Long openIssueCount(String type, String businessNo) {
return jdbc.queryForObject("""
select count(*) from payment_reconciliation_issue
where issue_type = ? and business_no = ? and status = 'open'
""", Long.class, type, businessNo);
}
private long count(String sql, Object... args) {
Long value = jdbc.queryForObject(sql, Long.class, args);
return value == null ? 0 : value;
}
}