yeg
2026-09-14 84913d997c70c2e3cc8406cf23eaba710cd5c9d6
fix(payment): 扫码核销也要确认资金,并补上确认失败的补偿重试

核销有三条入口,此前只有「订单管理里点确认」那条调用了交易确认:扫码核销
(/verification/confirm)和后台核销都只把订单置为已核销,资金会一直冻在通道延迟户,
商户永远收不到钱。三条入口现在都在核销后确认资金。

确认失败时状态有意保留 pending(不把钱判死),但订单已经核销、不会再有人走核销入口,
所以新增 PaymentFundConfirmationRetry 定时补偿:每 5 分钟挑出「已核销、支付成功、资金
未确认」的订单再确认一次,确认本身幂等,不会重复分账。

顺带修掉一处配置错误:新增的重试配置与已有的 app.payment.huifu 重名,导致 YAML
出现重复键、应用上下文直接启动失败。
2个文件已添加
6个文件已修改
219 ■■■■■ 已修改文件
.scratch/automatic-withdrawal/tools/RefundProbe.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationRetry.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/automatic-withdrawal/tools/RefundProbe.java
New file
@@ -0,0 +1,73 @@
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;&lt;deps&gt;;&lt;tools&gt;" RefundProbe &lt;huifu_id&gt; &lt;refund_no&gt; &lt;req_date&gt; [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;
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantVerificationService.java
@@ -12,6 +12,7 @@
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;
@@ -28,12 +29,15 @@
    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;
    }
    /**
@@ -214,6 +218,9 @@
                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"));
    }
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderService.java
@@ -11,6 +11,7 @@
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;
@@ -28,12 +29,14 @@
    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
@@ -118,6 +121,8 @@
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已核销");
        }
        // 后台核销是第三条核销入口,同样要在核销后把延迟入账的钱确认给商户。
        fundConfirmation.confirmForOrder(orderId);
        return detail(orderNo);
    }
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationRetry.java
New file
@@ -0,0 +1,74 @@
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;
    }
}
talent-card-server/src/main/resources/application.yml
@@ -42,6 +42,7 @@
    # 后台管理端(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}
@@ -55,6 +56,9 @@
    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
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationFreeStayTests.java
@@ -140,7 +140,9 @@
        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");
    }
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantVerificationOrderCodeTests.java
@@ -93,7 +93,9 @@
                )
                """);
        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");
    }
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java
@@ -52,6 +52,9 @@
    @Autowired
    private PaymentSettlementService settlementService;
    @Autowired
    private PaymentFundConfirmationRetry confirmationRetry;
    @MockitoBean
    private HuifuMiniProgramPaymentGateway paymentGateway;
@@ -142,6 +145,36 @@
        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);
@@ -156,10 +189,11 @@
    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");
    }
@@ -206,8 +240,14 @@
                "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, "", "", "");
    }