fix(payment): 扫码核销也要确认资金,并补上确认失败的补偿重试
核销有三条入口,此前只有「订单管理里点确认」那条调用了交易确认:扫码核销
(/verification/confirm)和后台核销都只把订单置为已核销,资金会一直冻在通道延迟户,
商户永远收不到钱。三条入口现在都在核销后确认资金。
确认失败时状态有意保留 pending(不把钱判死),但订单已经核销、不会再有人走核销入口,
所以新增 PaymentFundConfirmationRetry 定时补偿:每 5 分钟挑出「已核销、支付成功、资金
未确认」的订单再确认一次,确认本身幂等,不会重复分账。
顺带修掉一处配置错误:新增的重试配置与已有的 app.payment.huifu 重名,导致 YAML
出现重复键、应用上下文直接启动失败。
| New file |
| | |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; |
| | | import com.huifu.bspay.sdk.opps.client.BasePayClient; |
| | | import com.huifu.bspay.sdk.opps.core.BasePay; |
| | | import com.huifu.bspay.sdk.opps.core.config.MerConfig; |
| | | import com.huifu.bspay.sdk.opps.core.request.V3TradePaymentScanpayRefundqueryRequest; |
| | | |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * 查询一笔退款的通道状态(只读)。 |
| | | * |
| | | * <p>用法:java -cp "target/classes;<deps>;<tools>" RefundProbe <huifu_id> <refund_no> <req_date> [payment_no] |
| | | */ |
| | | public class RefundProbe { |
| | | |
| | | public static void main(String[] args) throws Exception { |
| | | String huifuId = args.length > 0 ? args[0] : "6666000226048687"; |
| | | String refundNo = args.length > 1 ? args[1] : ""; |
| | | String reqDate = args.length > 2 ? args[2] : ""; |
| | | String paymentNo = args.length > 3 ? args[3] : ""; |
| | | |
| | | JsonNode huifu = new YAMLMapper().readTree(Files.newInputStream( |
| | | Path.of("src", "main", "resources", "application.yml"))) |
| | | .path("app").path("payment").path("huifu"); |
| | | |
| | | BasePay.debug = false; |
| | | BasePay.prodMode = "prod".equalsIgnoreCase(text(huifu, "environment", "prod")) |
| | | ? BasePay.MODE_PROD : BasePay.MODE_TEST; |
| | | MerConfig config = new MerConfig(); |
| | | config.setSysId(text(huifu, "system-id", "").trim()); |
| | | config.setProductId(text(huifu, "product-id", "").trim()); |
| | | config.setRsaPrivateKey(text(huifu, "private-key", "").trim()); |
| | | config.setRsaPublicKey(text(huifu, "platform-public-key", "").trim()); |
| | | BasePay.initWithMerConfig(config); |
| | | |
| | | V3TradePaymentScanpayRefundqueryRequest request = new V3TradePaymentScanpayRefundqueryRequest(); |
| | | request.setHuifuId(huifuId.trim()); |
| | | request.setOrgReqDate(reqDate.trim()); |
| | | Map<String, Object> extend = new LinkedHashMap<>(); |
| | | if (!refundNo.isBlank()) extend.put("org_req_seq_id", refundNo.trim()); |
| | | if (!paymentNo.isBlank()) extend.put("org_hf_seq_id", paymentNo.trim()); |
| | | request.setExtendInfo(extend); |
| | | |
| | | System.out.println("== 退款查询 =="); |
| | | System.out.println(" huifu_id = " + huifuId); |
| | | System.out.println(" refund_no = " + refundNo); |
| | | System.out.println(" org_req_date= " + reqDate); |
| | | System.out.println(); |
| | | System.out.println("================ 退款查询原始回执 ================"); |
| | | try { |
| | | System.out.println(JSON.toJSONString(BasePayClient.request(request, false), true)); |
| | | } catch (Exception exception) { |
| | | System.out.println("!! 调用失败: " + exception.getClass().getSimpleName() + " : " + exception.getMessage()); |
| | | } |
| | | System.out.println("================================================"); |
| | | } |
| | | |
| | | private static String text(JsonNode node, String field, String fallback) { |
| | | String value = node.path(field).asText(""); |
| | | if (value.isBlank()) return fallback; |
| | | if (value.startsWith("${") && value.endsWith("}")) { |
| | | String inner = value.substring(2, value.length() - 1); |
| | | int separator = inner.indexOf(':'); |
| | | value = separator >= 0 ? inner.substring(separator + 1) : ""; |
| | | } |
| | | return value.isBlank() ? fallback : value; |
| | | } |
| | | } |
| | |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.merchant.auth.MerchantPrincipal; |
| | | import com.talentcard.server.business.order.OrderFundBoundaryService; |
| | | import com.talentcard.server.business.payment.PaymentFundConfirmationService; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.springframework.dao.DataAccessException; |
| | |
| | | private final JdbcTemplate jdbc; |
| | | private final ManualConsumptionService manualConsumptionService; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | private final PaymentFundConfirmationService fundConfirmation; |
| | | |
| | | public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService, |
| | | OrderFundBoundaryService orderFundBoundary) { |
| | | OrderFundBoundaryService orderFundBoundary, |
| | | PaymentFundConfirmationService fundConfirmation) { |
| | | this.jdbc = jdbc; |
| | | this.manualConsumptionService = manualConsumptionService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | this.fundConfirmation = fundConfirmation; |
| | | } |
| | | |
| | | /** |
| | |
| | | number(code.get("cardId")), number(code.get("benefitId")), ORDER_CODE, |
| | | number(code.get("payAmount")), number(code.get("subsidyAmount")), "订单动态核销码"); |
| | | finishAttempt(attempt, verifyNo); |
| | | // 核销通过后把钱放给商户:延迟入账的支付此前停在通道「待确认」,确认成功商户才真正拿到钱。 |
| | | // 扫码核销和订单管理里的核销是两条不同入口,两条都必须确认,否则线上主要走的那条不会出款。 |
| | | fundConfirmation.confirmForOrder(number(code.get("orderId"))); |
| | | return confirmed(verifyNo, code.get("cardLevel")); |
| | | } |
| | | |
| | |
| | | import com.talentcard.server.business.MoneyUtils; |
| | | import com.talentcard.server.business.merchant.MerchantPaymentReadinessService; |
| | | import com.talentcard.server.business.payment.HuifuRefundService; |
| | | import com.talentcard.server.business.payment.PaymentFundConfirmationService; |
| | | import com.talentcard.server.business.PageResult; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | |
| | | private final HuifuRefundService refundService; |
| | | private final MerchantPaymentReadinessService merchantPaymentReadinessService; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | private final PaymentFundConfirmationService fundConfirmation; |
| | | |
| | | public OrderService(JdbcTemplate jdbcTemplate, HuifuRefundService refundService, MerchantPaymentReadinessService merchantPaymentReadinessService, OrderFundBoundaryService orderFundBoundary) { |
| | | public OrderService(JdbcTemplate jdbcTemplate, HuifuRefundService refundService, MerchantPaymentReadinessService merchantPaymentReadinessService, OrderFundBoundaryService orderFundBoundary, PaymentFundConfirmationService fundConfirmation) { |
| | | this.jdbcTemplate = jdbcTemplate; |
| | | this.refundService = refundService; |
| | | this.merchantPaymentReadinessService = merchantPaymentReadinessService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | this.fundConfirmation = fundConfirmation; |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | if (updated != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已核销"); |
| | | } |
| | | // 后台核销是第三条核销入口,同样要在核销后把延迟入账的钱确认给商户。 |
| | | fundConfirmation.confirmForOrder(orderId); |
| | | return detail(orderNo); |
| | | } |
| | | |
| New file |
| | |
| | | package com.talentcard.server.business.payment; |
| | | |
| | | import java.time.LocalDate; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.scheduling.annotation.Scheduled; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | /** |
| | | * 延迟入账资金的确认补偿。 |
| | | * |
| | | * <p>核销时才调用交易确认,但确认可能失败或超时:失败时状态有意保留 {@code pending}(绝不把钱判死), |
| | | * 而订单已经核销,不会再有人触发核销入口。没有补偿的话这笔钱会永远冻在通道延迟户里。 |
| | | * |
| | | * <p>这里定期把「已核销、支付成功、资金仍未确认」的订单再确认一次。确认本身是幂等的,重复调用不会 |
| | | * 重复分账;失败原因会写在 {@code funds_confirm_code/message} 里供排查。 |
| | | */ |
| | | @Component |
| | | public class PaymentFundConfirmationRetry { |
| | | |
| | | private static final Logger log = LoggerFactory.getLogger(PaymentFundConfirmationRetry.class); |
| | | |
| | | /** 每轮最多补偿的订单数,避免一次拉太多拖长任务。 */ |
| | | private static final int BATCH_SIZE = 50; |
| | | |
| | | private final JdbcTemplate jdbc; |
| | | private final PaymentFundConfirmationService confirmationService; |
| | | |
| | | public PaymentFundConfirmationRetry(JdbcTemplate jdbc, PaymentFundConfirmationService confirmationService) { |
| | | this.jdbc = jdbc; |
| | | this.confirmationService = confirmationService; |
| | | } |
| | | |
| | | /** |
| | | * 补偿仍未确认的已核销订单。默认 5 分钟一轮;用 {@code app.payment.fund-confirmation-retry.enabled} |
| | | * 关闭,用 {@code ...fixed-delay-ms} 调整频率。 |
| | | */ |
| | | @Scheduled(fixedDelayString = "${app.payment.fund-confirmation-retry.fixed-delay-ms:300000}") |
| | | public void retryPendingConfirmations() { |
| | | try { |
| | | int retried = retry(BATCH_SIZE); |
| | | if (retried > 0) log.info("Retried fund confirmation for {} verified orders", retried); |
| | | } catch (RuntimeException exception) { |
| | | log.warn("Fund confirmation retry failed: {}", exception.getClass().getSimpleName()); |
| | | } |
| | | } |
| | | |
| | | /** 返回本轮真正尝试过的订单数(供测试与运维使用)。 */ |
| | | public int retry(int limit) { |
| | | List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList(""" |
| | | select distinct o.id as order_id |
| | | from benefit_order o |
| | | join payment_transaction p on p.order_id = o.id |
| | | where o.deleted = 0 and o.order_status = 1 and o.verify_status = 1 and o.refund_status = 0 |
| | | and p.status = 'SUCCEEDED' |
| | | and p.funds_confirmation_status in ('pending', 'confirming') |
| | | order by o.id asc |
| | | limit ? |
| | | """, Math.max(1, limit))); |
| | | int retried = 0; |
| | | for (Map<String, Object> row : rows) { |
| | | Object orderId = row.get("orderId"); |
| | | if (!(orderId instanceof Number number)) continue; |
| | | confirmationService.confirmForOrder(number.longValue()); |
| | | retried++; |
| | | } |
| | | return retried; |
| | | } |
| | | } |
| | |
| | | # 后台管理端(vite dev,默认 5273)与 HBuilderX Web 调试来源;部署时用 CORS_ALLOWED_ORIGINS 覆盖为真实域名。 |
| | | # localhost 与局域网地址都要列:换网段后局域网 IP 会变,localhost 是最稳的调试入口。 |
| | | allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5273,http://127.0.0.1:5273,http://localhost:7893,http://localhost:7894,http://127.0.0.1:7893,http://127.0.0.1:7894,http://192.168.110.26:5273,http://192.168.110.26:5173} |
| | | # 核销后把延迟入账的钱确认给商户;确认失败或超时时由这个补偿任务重试,避免钱永远冻在延迟户。 |
| | | privacy: |
| | | # Production must provide APP_PRIVACY_ENCRYPTION_KEY through the deployment secret store. |
| | | encryption-key: ${APP_PRIVACY_ENCRYPTION_KEY:talent-card-local-development-key} |
| | |
| | | enabled: false |
| | | talent-id: 2 |
| | | payment: |
| | | # 核销后把延迟入账的钱确认给商户;确认失败或超时时由补偿任务重试,避免钱永远冻在延迟户。 |
| | | fund-confirmation-retry: |
| | | fixed-delay-ms: ${FUND_CONFIRMATION_RETRY_FIXED_DELAY_MS:300000} |
| | | huifu: |
| | | # Keep disabled until the Huifu test/production product, merchant and keys are provisioned. |
| | | enabled: true |
| | |
| | | jdbc.execute("create table admin_setting (setting_key varchar(64) primary key, setting_value varchar(1000))"); |
| | | jdbc.update("insert into admin_setting(setting_key, setting_value) values ('hotelSubsidy', '268.50')"); |
| | | jdbc.execute("create table payment_transaction (id bigint primary key)"); |
| | | service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null), new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc)); |
| | | service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null), new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc), |
| | | // 这些用例只验证核销本身,资金确认用替身:确认逻辑由 PaymentFundConfirmationTests 覆盖。 |
| | | org.mockito.Mockito.mock(com.talentcard.server.business.payment.PaymentFundConfirmationService.class)); |
| | | principal = new MerchantPrincipal(9L, 88L, 7L, "operator"); |
| | | } |
| | | |
| | |
| | | ) |
| | | """); |
| | | service = new MerchantVerificationService(jdbc, new ManualConsumptionService(jdbc, null), |
| | | new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc)); |
| | | new com.talentcard.server.business.order.OrderFundBoundaryService(jdbc), |
| | | // 这些用例只验证核销本身,资金确认用替身:确认逻辑由 PaymentFundConfirmationTests 覆盖。 |
| | | org.mockito.Mockito.mock(com.talentcard.server.business.payment.PaymentFundConfirmationService.class)); |
| | | principal = new MerchantPrincipal(9L, 88L, 7L, "operator"); |
| | | } |
| | | |
| | |
| | | @Autowired |
| | | private PaymentSettlementService settlementService; |
| | | |
| | | @Autowired |
| | | private PaymentFundConfirmationRetry confirmationRetry; |
| | | |
| | | @MockitoBean |
| | | private HuifuMiniProgramPaymentGateway paymentGateway; |
| | | |
| | |
| | | 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")); |
| | | |
| | | assertThat(confirmationRetry.retry(50)).isPositive(); |
| | | |
| | | 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); |
| | | insertPaidPayment(orderNo, 100L, "confirmed"); |
| | | jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId(orderNo)); |
| | | |
| | | confirmationRetry.retry(50); |
| | | |
| | | verify(delayGateway, never()).confirm(any()); |
| | | } |
| | | |
| | | @Test |
| | | void anOrderThatWasNotDelayedNeedsNoConfirmation() { |
| | | String orderNo = createOrder("STORE_CONSUME", 100L); |
| | |
| | | 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); |
| | | verify(delayGateway, never()).confirm(any()); |
| | | assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?", |
| | | String.class, paymentNo)).isEqualTo("pending"); |
| | | } |
| | |
| | | "00000000", "交易成功", "0.00", confirmedYuan, "HF-" + System.nanoTime()); |
| | | } |
| | | |
| | | private HuifuDelayAccountGateway.HuifuDelayConfirmation failed(String code, String message) { |
| | | /** 通道还在处理:状态保持 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, "", "", ""); |
| | | } |