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; /** * 延迟入账与资金确认。 * *

业务规则:先付钱后核销的订单,钱必须留在通道「待确认」,核销确认后才分配给商户;支付即核销的 * 到店消费订单不延迟,也不该被这个流程影响。 */ @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 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 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", "账户余额不足"); } /** * 返回码语义未定时绝不能算成功。 * *

{@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 初始化失败)时,钱没有动,可以重试。 * *

不能报成「已结算到银行卡」:那是另一回事,商户照着这句话去找平台就白跑了。 */ @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 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"); } /** * 放款前必须复查订单仍是「已核销」:撤销核销会把订单翻回待核销,钱必须留在延迟户。 * *

没有这道复查,补偿任务(或在途的确认请求)会在撤销之后把钱打给商户,而订单已经回到待核销。 */ @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); } /** * 造一笔「延迟入账 + 资金已确认给商户」的订单,返回支付流水号。 * *

确认走真实服务而不是直接改字段:撤销时用来定位原确认的请求号与请求日期,正是它写下的。 */ 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()); } /** * 当天认领出来的「确认中」靠**确认查询**收敛,而不是整整等一天。 * *

认领规则不允许当天重发确认(避免重复分账),于是同一天的确认中在旧实现里没人再看——而确认查询 * 接口({@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 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); } }