package com.talentcard.server.business.payment;
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.when;
|
|
import java.math.BigDecimal;
|
import java.time.LocalDate;
|
import java.time.LocalDateTime;
|
import java.time.format.DateTimeFormatter;
|
import java.util.Map;
|
|
import com.talentcard.server.business.order.OrderRefundRequest;
|
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway;
|
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentCommand;
|
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
|
import com.talentcard.server.business.portal.AppOrderCreateRequest;
|
import com.talentcard.server.business.portal.AppOrderPortalService;
|
import com.talentcard.server.common.BusinessException;
|
import com.talentcard.server.common.ErrorCode;
|
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;
|
|
/**
|
* 延迟入账与资金确认。
|
*
|
* <p>业务规则:先付钱后核销的订单,钱必须留在通道「待确认」,核销确认后才分配给商户;支付即核销的
|
* 到店消费订单不延迟,也不该被这个流程影响。
|
*/
|
@SpringBootTest
|
@ActiveProfiles("test")
|
class PaymentFundConfirmationTests {
|
|
private static final String HUIFU_ID = "6666000226048687";
|
|
@Autowired
|
private JdbcTemplate jdbc;
|
|
@Autowired
|
private AppOrderPortalService orderPortalService;
|
|
@Autowired
|
private HuifuPaymentService paymentService;
|
|
@Autowired
|
private PaymentFundConfirmationService confirmationService;
|
|
@Autowired
|
private PaymentSettlementService settlementService;
|
|
@Autowired
|
private PaymentFundConfirmationRetry confirmationRetry;
|
|
@MockitoBean
|
private HuifuMiniProgramPaymentGateway paymentGateway;
|
|
@MockitoBean
|
private HuifuDelayAccountGateway delayGateway;
|
|
private Long merchantId;
|
private Long storeId;
|
private Long talentId;
|
private Long productId;
|
private String suffix;
|
|
@BeforeEach
|
void prepare() {
|
suffix = String.valueOf(System.nanoTime());
|
jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "延迟入账商户 " + suffix);
|
merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
|
jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
|
"delay-" + suffix, "延迟入账用户", "13900000009");
|
talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
|
jdbc.update("insert into talent_card(talent_id, card_no, status) values (?, ?, 1)", talentId, "DC" + suffix);
|
jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)",
|
merchantId, "延迟入账门店 " + suffix);
|
storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
|
// 下单要求商户的收款能力已确认:进件通过 + 斗拱商户号 + 能力快照(自动结算已关、取现可用)。
|
jdbc.update("""
|
insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
|
contact_name, contact_mobile, idempotency_key, status, provider_huifu_id,
|
auto_settle_status, cash_switch_status, merchant_stat, capability_verified_at)
|
values (?, ?, ?, 'individual', 'Delay owner', 'Delay owner', '13900000009', ?, 'approved', ?, '0', '1', 'NORMAL', current_timestamp)
|
""", merchantId, storeId, "MO-DELAY-" + suffix, "delay-onboarding-" + suffix, HUIFU_ID);
|
Long onboardingId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
|
jdbc.update("""
|
insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
|
card_hash, status, idempotency_key, provider_token_no, reviewed_at)
|
values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?, current_timestamp)
|
""", merchantId, storeId, "hash-delay-" + suffix, "delay-card-" + suffix, "T" + suffix);
|
Long cardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
|
jdbc.update("update merchant_onboarding_application set initial_bank_card_id = ? where id = ?", cardId, onboardingId);
|
// 收款就绪要求微信支付业务开通与实名/授权都有通道证据,这里的商户建模成已办完这两步。
|
jdbc.update("insert into merchant_onboarding_progress(application_id, stages_json) values (?, '{\"wechat\":\"confirmed\",\"realname\":\"confirmed\"}')", onboardingId);
|
jdbc.update("""
|
insert into merchant_product(merchant_id, product_no, product_type, product_name, original_price,
|
sale_price, stock, audit_status, status, deleted)
|
values (?, ?, 'GROUP_BUY', '延迟入账商品', 100.00, 100.00, -1, 2, 1, 0)
|
""", merchantId, "DELAY-PRODUCT-" + suffix);
|
productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class);
|
}
|
|
@Test
|
void aDelayedOrderHoldsTheMoneyUntilItIsConfirmed() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
|
markVerified(orderId(orderNo));
|
|
when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
|
|
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.CONFIRMED);
|
|
Map<String, Object> payment = jdbc.queryForMap(
|
"select funds_confirmation_status, funds_confirmed_amount, funds_confirmed_at, funds_confirm_no"
|
+ " from payment_transaction where payment_no = ?", paymentNo);
|
assertThat(payment).containsEntry("funds_confirmation_status", "confirmed");
|
// 通道回执的已确认金额是「元」字符串,本地存的是分。
|
assertThat(((Number) payment.get("funds_confirmed_amount")).longValue()).isEqualTo(100L);
|
assertThat(payment.get("funds_confirmed_at")).isNotNull();
|
assertThat(String.valueOf(payment.get("funds_confirm_no"))).isNotBlank();
|
}
|
|
@Test
|
void anAlreadyConfirmedOrderIsNeverConfirmedTwice() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
insertPaidPayment(orderNo, 100L, "pending");
|
markVerified(orderId(orderNo));
|
when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
|
|
confirmationService.confirmForOrder(orderId(orderNo));
|
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.CONFIRMED);
|
|
verify(delayGateway).confirm(any());
|
}
|
|
@Test
|
void aConfirmationAlreadyInFlightIsNotStartedAgain() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
insertPaidPayment(orderNo, 100L, "confirming");
|
markVerified(orderId(orderNo));
|
|
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.PROCESSING);
|
verify(delayGateway, never()).confirm(any());
|
}
|
|
/**
|
* 确认失败时状态有意保留 pending,而订单已经核销、不会再有人走核销入口。
|
* 没有补偿的话这笔钱会永远冻在通道延迟户里。
|
*/
|
@Test
|
void aPendingConfirmationOfAVerifiedOrderIsRetriedWithoutAnotherVerification() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
|
long orderId = orderId(orderNo);
|
jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId);
|
when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
|
// 补偿任务扫的是整库、按订单号从小到大取一批,而测试库共享且不清理。先把自己之外的相关行移出
|
// 扫描条件(那些用例已经跑完),本用例这条才一定落在窗口内——否则它会随测试库累积而必然失败
|
// (docs/agents/testing.md 记的「环境相关失败」)。
|
jdbc.update("""
|
update benefit_order set refund_status = 1
|
where id <> ? and deleted = 0 and order_status = 1 and verify_status = 1 and refund_status = 0
|
and exists (select 1 from payment_transaction p
|
where p.order_id = benefit_order.id and p.status = 'SUCCEEDED'
|
and p.funds_confirmation_status in ('pending', 'confirming', 'reverting'))
|
""", orderId);
|
|
assertThat(confirmationRetry.retry(50)).isEqualTo(1);
|
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
|
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
|
}
|
|
/** 已经确认过的订单不该被补偿任务反复打扰通道。 */
|
@Test
|
void theRetrySkipsOrdersWhoseFundsAreAlreadyConfirmed() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "confirmed");
|
markVerified(orderId(orderNo));
|
// 补偿任务扫的是整库,会顺带处理其它用例留下的「已核销 + 待确认」订单,所以给它们一个可用的
|
// 回执,并把断言收窄到本单:要证明的是这笔已经确认过的钱不会被再确认一次。
|
when(delayGateway.confirm(any())).thenReturn(processing());
|
|
confirmationRetry.retry(50);
|
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
|
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
|
verify(delayGateway, never()).confirm(argThat(command -> paymentNo.equals(command.paymentNo())));
|
}
|
|
@Test
|
void anOrderThatWasNotDelayedNeedsNoConfirmation() {
|
String orderNo = createOrder("STORE_CONSUME", 100L);
|
insertPaidPayment(orderNo, 100L, null);
|
|
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.NOT_DELAYED);
|
verify(delayGateway, never()).confirm(any());
|
}
|
|
@Test
|
void anUnsettledPaymentIsLeftForLater() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPayment(orderNo, 100L, "PREPAY_CREATED", "pending");
|
// 确认补偿任务也在跑,会挑走库里其它用例留下的已核销订单;这里只断言本用例这笔不受影响。
|
when(delayGateway.confirm(any())).thenReturn(processing());
|
|
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.NOT_SETTLED);
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
|
String.class, paymentNo)).isEqualTo("pending");
|
}
|
|
@Test
|
void aRejectedConfirmationStaysRetryableInsteadOfLosingTheMoney() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
|
markVerified(orderId(orderNo));
|
when(delayGateway.confirm(any())).thenReturn(failed("22000002", "商户信息不存在"));
|
|
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.FAILED);
|
|
assertThat(jdbc.queryForMap("select funds_confirmation_status, funds_confirm_message"
|
+ " from payment_transaction where payment_no = ?", paymentNo))
|
.containsEntry("funds_confirmation_status", "pending")
|
.containsEntry("funds_confirm_message", "商户信息不存在");
|
}
|
|
@Test
|
void aConfirmedOrderCanBeRefundedWhileAnUnconfirmedOneIsNotWithdrawn() {
|
// 这个用例只断言资金状态这一维:确认前订单不可提现,确认后才可提现(提现门槛本身在
|
// AutoWithdrawalAggregationTests 覆盖)。退款路径的改造尚未完成,这里不做断言。
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
insertPaidPayment(orderNo, 100L, "pending");
|
long orderId = orderId(orderNo);
|
jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId);
|
|
when(delayGateway.confirm(any())).thenReturn(succeeded("100"));
|
confirmationService.confirmForOrder(orderId);
|
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
|
+ " where order_id = ?", String.class, orderId)).isEqualTo("confirmed");
|
assertThat(new OrderRefundRequest(BigDecimal.ONE, "test", "tester")).isNotNull();
|
}
|
|
/**
|
* 撤销核销要把已确认给商户的钱退回通道延迟户,这是「撤销之后钱又变成不可结算」的唯一保证。
|
*/
|
@Test
|
void aRevertPutsTheMoneyBackInTheDelayAccount() {
|
String paymentNo = confirmedDelayOrder(100L);
|
when(delayGateway.refund(any())).thenReturn(revertSucceeded());
|
|
assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo(
|
PaymentFundConfirmationService.REVERTED);
|
|
Map<String, Object> payment = jdbc.queryForMap("""
|
select funds_confirmation_status, funds_revert_no, funds_revert_request_date,
|
funds_reverted_amount, funds_reverted_at, funds_confirm_no
|
from payment_transaction where payment_no = ?
|
""", paymentNo);
|
// 钱回到延迟户:状态退回 pending,商户重新核销时会走一次全新的交易确认。
|
assertThat(payment).containsEntry("funds_confirmation_status", "pending");
|
assertThat(String.valueOf(payment.get("funds_revert_no"))).isNotBlank();
|
assertThat(String.valueOf(payment.get("funds_revert_request_date"))).isNotBlank();
|
assertThat(((Number) payment.get("funds_reverted_amount")).longValue()).isEqualTo(100L);
|
assertThat(payment.get("funds_reverted_at")).isNotNull();
|
// 上一轮确认的回执要留着:它是「这笔钱曾经确认过」的证据。
|
assertThat(String.valueOf(payment.get("funds_confirm_no"))).isNotBlank();
|
}
|
|
/**
|
* 通道明确拒绝(例如钱已被 D1 结算划到商户银行卡):钱没有动,状态回到可重试的 confirmed,
|
* 回执留作证据。调用方据此拒绝撤销核销。
|
*/
|
@Test
|
void aRejectedRevertKeepsTheMoneyWithTheMerchant() {
|
String paymentNo = confirmedDelayOrder(100L);
|
when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert(
|
HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.FAILED, "22000003", "账户余额不足", ""));
|
|
assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo(
|
PaymentFundConfirmationService.REVERT_REJECTED);
|
|
assertThat(jdbc.queryForMap("select funds_confirmation_status, funds_revert_message"
|
+ " from payment_transaction where payment_no = ?", paymentNo))
|
.containsEntry("funds_confirmation_status", "confirmed")
|
.containsEntry("funds_revert_message", "账户余额不足");
|
}
|
|
/**
|
* 返回码语义未定时绝不能算成功。
|
*
|
* <p>{@code 23000003} 在交易确认那条路上是「已无未确认金额」=幂等成功(有真实回执复验过),
|
* 但在回退这条路上没有回执证明它的含义。判成成功会让订单翻回「待核销」而钱还在商户基本户,
|
* 因此只能停在 reverting 等人工核对。
|
*/
|
@Test
|
void anUndeterminedRevertCodeNeverCountsAsSuccess() {
|
String paymentNo = confirmedDelayOrder(100L);
|
when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert(
|
HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.NEEDS_REVIEW, "23000003", "已无已确认金额", ""));
|
|
assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo(
|
PaymentFundConfirmationService.REVERT_NEEDS_REVIEW);
|
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
|
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("reverting");
|
}
|
|
/**
|
* 请求根本没发出去(配置不完整、SDK 初始化失败)时,钱没有动,可以重试。
|
*
|
* <p>不能报成「已结算到银行卡」:那是另一回事,商户照着这句话去找平台就白跑了。
|
*/
|
@Test
|
void aRevertThatNeverReachedTheProviderIsRetryable() {
|
String paymentNo = confirmedDelayOrder(100L);
|
when(delayGateway.refund(any())).thenThrow(new BusinessException(ErrorCode.CONFLICT, "汇付支付配置不完整"));
|
|
assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo(
|
PaymentFundConfirmationService.REVERT_NOT_SENT);
|
|
Map<String, Object> payment = jdbc.queryForMap("select funds_confirmation_status, funds_revert_no,"
|
+ " funds_revert_code, funds_revert_message from payment_transaction where payment_no = ?", paymentNo);
|
assertThat(payment).containsEntry("funds_confirmation_status", "confirmed")
|
.containsEntry("funds_revert_code", "LOCAL_ERROR");
|
// 请求号是给通道侧定位用的,这次通道没收到,不能留在库里冒充一次回退。
|
assertThat(payment.get("funds_revert_no")).isNull();
|
assertThat(String.valueOf(payment.get("funds_revert_message"))).isNotBlank();
|
}
|
|
/** 钱还在延迟户:没有已确认的资金要回退,也不该白打一次通道。 */
|
@Test
|
void moneyStillInTheDelayAccountNeedsNoRevert() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
insertPaidPayment(orderNo, 100L, "pending");
|
markVerified(orderId(orderNo));
|
|
assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.REVERT_NOT_NEEDED);
|
verify(delayGateway, never()).refund(any());
|
}
|
|
/** 支付即核销的到店消费不走延迟入账,钱从来不在延迟户,也就没有回退这回事。 */
|
@Test
|
void aNonDelayedPaymentNeedsNoRevert() {
|
String orderNo = createOrder("STORE_CONSUME", 100L);
|
insertPaidPayment(orderNo, 100L, null);
|
markVerified(orderId(orderNo));
|
|
assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.REVERT_NOT_NEEDED);
|
verify(delayGateway, never()).refund(any());
|
}
|
|
/** 已经在回退中的资金不重复发请求:钱的位置未定,撤销核销要等它收敛。 */
|
@Test
|
void aRevertInFlightIsNotStartedAgain() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
insertPaidPayment(orderNo, 100L, "reverting");
|
markVerified(orderId(orderNo));
|
|
assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.REVERT_IN_FLIGHT);
|
verify(delayGateway, never()).refund(any());
|
}
|
|
/** 没有原确认的定位键就撤销不了确认:保持原状交人工,不能假装钱已经退回延迟户。 */
|
@Test
|
void aConfirmedPaymentWithoutTheConfirmationReferenceNeedsAHuman() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "confirmed");
|
markVerified(orderId(orderNo));
|
|
assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.REVERT_UNKNOWN);
|
verify(delayGateway, never()).refund(any());
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
|
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
|
}
|
|
/**
|
* 放款前必须复查订单仍是「已核销」:撤销核销会把订单翻回待核销,钱必须留在延迟户。
|
*
|
* <p>没有这道复查,补偿任务(或在途的确认请求)会在撤销之后把钱打给商户,而订单已经回到待核销。
|
*/
|
@Test
|
void aConfirmationIsRefusedOnceTheOrderIsNoLongerVerified() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
|
jdbc.update("update benefit_order set order_status = 1, verify_status = 0 where id = ?", orderId(orderNo));
|
|
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.NOT_VERIFIED);
|
verify(delayGateway, never()).confirm(any());
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
|
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("pending");
|
}
|
|
// ------------------------------------------------------------------------------------------
|
// helpers
|
// ------------------------------------------------------------------------------------------
|
|
private HuifuDelayAccountGateway.HuifuDelayConfirmation succeeded(String confirmedYuan) {
|
return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
|
HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
|
"00000000", "交易成功", "0.00", confirmedYuan, "HF-" + System.nanoTime());
|
}
|
|
private HuifuDelayAccountGateway.HuifuDelayRevert revertSucceeded() {
|
return new HuifuDelayAccountGateway.HuifuDelayRevert(
|
HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED,
|
"00000000", "交易成功", "HF-R-" + System.nanoTime());
|
}
|
|
/** 资金确认与资金回退都要求订单处于「已核销」。 */
|
private void markVerified(long orderId) {
|
jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId);
|
}
|
|
/**
|
* 造一笔「延迟入账 + 资金已确认给商户」的订单,返回支付流水号。
|
*
|
* <p>确认走真实服务而不是直接改字段:撤销时用来定位原确认的请求号与请求日期,正是它写下的。
|
*/
|
private String confirmedDelayOrder(long amountInCent) {
|
String orderNo = createOrder("GROUP_BUY", amountInCent);
|
String paymentNo = insertPaidPayment(orderNo, amountInCent, "pending");
|
long orderId = orderId(orderNo);
|
markVerified(orderId);
|
when(delayGateway.confirm(any())).thenReturn(succeeded(BigDecimal.valueOf(amountInCent, 2).toPlainString()));
|
confirmationService.confirmForOrder(orderId);
|
return paymentNo;
|
}
|
|
private long orderIdOfPayment(String paymentNo) {
|
return jdbc.queryForObject("select order_id from payment_transaction where payment_no = ?",
|
Long.class, paymentNo);
|
}
|
|
/**
|
* 通道说「已无未确认金额」表示这笔延迟交易此前已经确认过(重新确认不会重复分账)。
|
* 必须按成功处理:否则补偿任务会一直认为没确认,每隔几分钟无意义地打一次通道。
|
*/
|
@Test
|
void nothingLeftToConfirmCountsAsAlreadyConfirmed() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "pending");
|
markVerified(orderId(orderNo));
|
when(delayGateway.confirm(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayConfirmation(
|
HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
|
"23000003", "已无未确认金额", "", "", ""));
|
|
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.CONFIRMED);
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
|
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
|
}
|
|
/**
|
* 认领之后进程就没了(部署、崩溃)会留下 {@code confirming} 的孤儿:补偿任务此前只挑得到它、
|
* 从不重发,这笔钱就永远冻在延迟户。隔天必须能重新确认一次。
|
*/
|
@Test
|
void aConfirmationAbandonedByACrashIsRetriedTheNextDay() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "confirming");
|
long orderId = orderId(orderNo);
|
markVerified(orderId);
|
jdbc.update("update payment_transaction set funds_confirm_request_date = ? where payment_no = ?",
|
LocalDate.now().minusDays(1).format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo);
|
when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
|
|
assertThat(confirmationService.confirmForOrder(orderId)).isEqualTo(
|
PaymentFundConfirmationService.CONFIRMED);
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
|
+ " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
|
}
|
|
/** 今天认领的确认不重发:它可能还在通道里处理。 */
|
@Test
|
void aConfirmationClaimedTodayIsNotSentAgain() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "confirming");
|
markVerified(orderId(orderNo));
|
jdbc.update("update payment_transaction set funds_confirm_request_date = ? where payment_no = ?",
|
LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo);
|
|
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.PROCESSING);
|
verify(delayGateway, never()).confirm(any());
|
}
|
|
/**
|
* 当天认领出来的「确认中」靠**确认查询**收敛,而不是整整等一天。
|
*
|
* <p>认领规则不允许当天重发确认(避免重复分账),于是同一天的确认中在旧实现里没人再看——而确认查询
|
* 接口({@code delaytrans.confirmquery})从写下那天起就是死代码。这里把它接上。
|
*/
|
@Test
|
void aConfirmationClaimedTodayConvergesThroughTheConfirmationQuery() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "confirming");
|
markVerified(orderId(orderNo));
|
jdbc.update("""
|
update payment_transaction
|
set funds_confirm_request_date = ?, funds_confirm_no = ?
|
where payment_no = ?
|
""", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), "DC-CLAIMED-" + paymentNo, paymentNo);
|
when(delayGateway.queryConfirm(any(), any())).thenReturn(succeeded("1.00"));
|
|
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
|
PaymentFundConfirmationService.CONFIRMED);
|
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
|
String.class, paymentNo)).isEqualTo("confirmed");
|
verify(delayGateway, never()).confirm(any());
|
}
|
|
/**
|
* 撤销核销的回退停在 {@code reverting} 时也要有人再看:回退没有查询接口,补偿任务原先只扫
|
* 「待确认/确认中」,这类记录会永久卡住撤销核销。幂等的重发就是它的查询对等物。
|
*/
|
@Test
|
void anInFlightRevertConvergesThroughTheRetryTask() {
|
String orderNo = createOrder("GROUP_BUY", 100L);
|
String paymentNo = insertPaidPayment(orderNo, 100L, "reverting");
|
long orderId = orderId(orderNo);
|
markVerified(orderId);
|
jdbc.update("""
|
update payment_transaction
|
set funds_confirm_no = ?, funds_confirm_request_date = ?, funds_revert_no = ?,
|
funds_revert_request_date = ?, funds_confirmed_amount = 100
|
where payment_no = ?
|
""", "DC-ORIGINAL-" + paymentNo, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE),
|
"DR-INFLIGHT-" + paymentNo, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo);
|
// 补偿任务按订单号从小到大取一批,而测试库共享且不清理:先把别人的相关行移出扫描条件。
|
jdbc.update("""
|
update benefit_order set refund_status = 1
|
where id <> ? and deleted = 0 and order_status = 1 and verify_status = 1 and refund_status = 0
|
and exists (select 1 from payment_transaction p
|
where p.order_id = benefit_order.id and p.status = 'SUCCEEDED'
|
and p.funds_confirmation_status in ('pending', 'confirming', 'reverting'))
|
""", orderId);
|
when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert(
|
HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED, "00000000", "交易成功", "HF-REVERT-RETRY"));
|
|
assertThat(confirmationRetry.retry(50)).isEqualTo(1);
|
|
assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
|
String.class, paymentNo))
|
.as("回退成功了,钱重新回到延迟户(pending):撤销核销不再被永久卡住")
|
.isEqualTo("pending");
|
}
|
|
/** 通道还在处理:状态保持 pending,供确认补偿任务下一轮再试。 */
|
private HuifuDelayAccountGateway.HuifuDelayConfirmation processing() {
|
return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
|
HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.PROCESSING,
|
"00000100", "交易处理中", "1.00", "0.00", "");
|
}
|
|
private HuifuDelayAccountGateway.HuifuDelayConfirmation failed(String code, String message) { return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
|
HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.FAILED,
|
code, message, "", "", "");
|
}
|
|
private String createOrder(String orderType, long amountInCent) {
|
Map<String, Object> created = orderPortalService.create(talentId,
|
new AppOrderCreateRequest(merchantId, productId, null), "delay-fund-" + System.nanoTime());
|
String orderNo = String.valueOf(created.get("orderNo"));
|
jdbc.update("update benefit_order set order_type = ?, verify_status = 0 where order_no = ?", orderType, orderNo);
|
return orderNo;
|
}
|
|
private String insertPaidPayment(String orderNo, long amountInCent, String fundsStatus) {
|
return insertPayment(orderNo, amountInCent, "SUCCEEDED", fundsStatus);
|
}
|
|
private String insertPayment(String orderNo, long amountInCent, String status, String fundsStatus) {
|
String paymentNo = "P-DELAY-" + System.nanoTime();
|
Long orderId = orderId(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, attempt_no,
|
paid_at, funds_confirmation_status)
|
values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
""", paymentNo, orderId, orderNo, talentId, merchantId, HUIFU_ID, amountInCent, status,
|
"delay-payment-" + paymentNo, LocalDateTime.now(), fundsStatus);
|
return paymentNo;
|
}
|
|
private long orderId(String orderNo) {
|
return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
|
}
|
}
|