yeg
2026-09-17 e71919933b57c4284e19cad12b4791ef52a58c8a
feat(payment): 悬账自动退款与部分退款

通道确认收到、但订单已经取消的钱(悬账)原先只在账目上留一条问题记录等人工,没有任何自动出口;
而首笔部分退款之后剩余金额永远退不了。两件都是 2026-09-10 审计列出的未落地项(§8 后半段、§14)。

- 新增窄口子退款入口 refundStranded(orderNo):断言订单已取消、通道确认收过钱、未退款、无退款记录,
仍经 OrderFundBoundaryService 锁订单行(与撤销核销、交易确认、普通退款串行化)。
- 放宽支付键可见性:从「仅已成功流水」到「已成功,或已关闭且通道已确认成功」——旧实现下这类悬账
连支付键都看不到,退款无从发起。
- 新增定时消费者 StrandedPaymentRefundRetry:扫「订单已取消但通道确认收款」的问题记录 → 调窄口子
退款 → 受理成功标为已解决;通道拒绝则升级告警(另记 STRANDED_REFUND_REJECTED 并 log.error),
问题记录保持未结,绝不静默吞掉。
- 部分退款改为按「已成功金额 + 处理中金额 < 实付」控制,退款能否发起由订单行锁 + 金额额度判定,
不再因「存在退款中或已退款」而拒绝。

回归:StrandedPaymentRefundTests(悬账退发起并结掉问题、通道拒绝走告警、已关闭但通道确认成功的支付
仍能被定位、窄口子拒绝非悬账与在途退款、100 退 40 再退 60 且超额被拒、人才端列表 refundStatus=2)。
2个文件已添加
3个文件已修改
565 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderFundBoundaryService.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/StrandedPaymentRefundRetry.java 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/StrandedPaymentRefundTests.java 319 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/resources/application-test.yml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/order/OrderFundBoundaryService.java
@@ -58,15 +58,18 @@
        return LockedOrder.fromRow(rows.get(0));
    }
    /** 退款受理成功:本笔退款结清前订单不可核销,也不参与提现汇总。 */
    /**
     * 退款受理成功:本笔退款结清前订单不可核销,也不参与提现汇总。
     *
     * <p>不在这里判断「能不能退」:现行规则是按金额算的(已成功 + 处理中 &lt; 实付 才能再退),
     * 所以第二笔部分退款是允许的,调用方已经在订单行锁里判过金额。这里只是把订单标记成「有退款在途」,
     * 让核销与提现汇总看见它。
     */
    public void markRefundInProgress(long orderId) {
        int updated = jdbc.update("""
        jdbc.update("""
                update benefit_order set refund_status = 1
                where id = ? and deleted = 0 and refund_status = 0
                """, orderId);
        if (updated != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "订单已提交退款申请");
        }
    }
    /** 退款成功:整单退出可核销与可提现范围,部分退款同样按整单处理。 */
@@ -109,16 +112,32 @@
            }
        }
        /** 未支付、已退款或退款中的订单都不能再退款。 */
        /**
         * 未支付、已退款或退款中的订单都不能再退款。
         *
         * <p>现行规则只挡住「没收到钱」:能不能再退由调用方按金额判断(已成功金额 + 处理中金额 &lt; 实付),
         * 所以首笔部分退款之后剩余金额仍然退得掉。旧的「有退款中或已退款就拒绝」会让剩余的钱永远退不了
         * (审计 §14)。
         */
        public void requireRefundable() {
            if (orderStatus != 1) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单当前状态不可退款");
            }
            if (refundStatus == REFUND_IN_PROGRESS) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单退款处理中,不能重复退款");
        }
        /**
         * 悬账退款只认「订单已取消、还没有退款」的订单。
         *
         * <p>这条窄口子服务的对象只有一个:通道确认收过钱、但订单已经取消的**悬账**。它有唯一正确的
         * 出路——原路退回付款人,所以不需要判断核销状态,但必须确认订单真的已取消,否则就会绕过正常
         * 退款路径的前置条件(例如把一笔还在待核销的订单退掉)。
         */
        public void requireStrandedRefundable() {
            if (orderStatus != 2) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单未取消,不能按悬账退款");
            }
            if (refundStatus == REFUNDED) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单已退款");
            if (refundStatus != REFUND_NONE) {
                throw new BusinessException(ErrorCode.CONFLICT, "订单已退款或退款中,不能重复退款");
            }
        }
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
@@ -13,6 +13,8 @@
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@@ -31,6 +33,7 @@
    private static final String PROVIDER = "HUIFU";
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    private static final Logger log = LoggerFactory.getLogger(HuifuRefundService.class);
    private final JdbcTemplate jdbc;
    private final TransactionTemplate transactions;
@@ -47,9 +50,56 @@
    }
    public Map<String, Object> request(String orderNo, OrderRefundRequest request) {
        PreparedRefund prepared = transactions.execute(status -> prepare(orderNo, request));
        PreparedRefund prepared = transactions.execute(status -> prepare(orderNo, request, false));
        if (prepared == null) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Refund initialization failed");
        return submit(prepared);
    }
    /**
     * 悬账自动退款:通道确认收到、但订单已经取消的钱,原路退回付款人。
     *
     * <p>这是**窄口子**入口,只服务一种情形({@code requireStrandedRefundable}):订单已取消、通道确认
     * 收过钱、还没退、也没有退款记录。它有唯一正确的出路(退回付款人),没有需要人判断的地方;留人工只会
     * 让它一直挂在页面上。它**不绕过**资金边界:仍然先锁订单行,因此与撤销核销、交易确认、普通退款互相
     * 串行化,不会互踩。
     *
     * <p>它不返回通道的最终结论——退款本身也要等通道出结果,那由退款对账与读路径收敛。这里只回答
     * 「有没有把退款发出去」,因为调用方(定时消费者)据此决定是结掉问题记录还是升级告警。
     */
    public StrandedRefundOutcome refundStranded(String orderNo) {
        PreparedRefund prepared;
        try {
            prepared = transactions.execute(status -> prepare(orderNo, new OrderRefundRequest(
                    BigDecimal.ZERO, "订单取消后通道确认收款,自动退回付款人", "system"), true));
        } catch (BusinessException exception) {
            // 这条窄口子不适用(订单没取消、支付没有通道确认的成功事实、已经退过):不是通道拒绝,
            // 不该升级告警,交回调用方按自己的规则处理。
            return StrandedRefundOutcome.NOT_APPLICABLE;
        }
        if (prepared == null) return StrandedRefundOutcome.NOT_APPLICABLE;
        try {
            submit(prepared);
            return StrandedRefundOutcome.SUBMITTED;
        } catch (BusinessException exception) {
            // 通道明确拒绝(例如商户基本户余额不足):必须转人工,绝不能静默吞掉。
            log.warn("Stranded refund {} for order {} was rejected: {}",
                    prepared.refundNo(), orderNo, exception.getMessage());
            return StrandedRefundOutcome.REJECTED;
        }
    }
    /** 悬账自动退款的结论。 */
    public enum StrandedRefundOutcome {
        /** 退款请求已被通道受理:最终结果由退款对账与读路径收敛。 */
        SUBMITTED,
        /** 通道明确拒绝:要升级告警转人工。 */
        REJECTED,
        /** 没有可自动退的悬账:不需要处理。 */
        NOT_APPLICABLE
    }
    /** 把一笔已准备好的退款发给通道,并记录通道的答复。 */
    private Map<String, Object> submit(PreparedRefund prepared) {
        Map<String, Object> response;
        try {
            response = gateway.createRefund(new HuifuRefundCommand(
@@ -163,33 +213,51 @@
        return rows.get(0);
    }
    private PreparedRefund prepare(String orderNo, OrderRefundRequest request) {
    /**
     * 准备一笔退款:锁定订单行、确认这笔退款合法、算出可退金额、落一条退款记录。
     *
     * <p>「支付键可见性」不是只看已成功流水:本地已关闭、但通道确认收过钱的那一类悬账
     * ({@code CLOSED} + 一条「取消后才收到钱」的问题记录)也必须能被定位到原支付流水,
     * 否则连退款都发起不了——而这类钱的唯一出路就是退回付款人。
     *
     * @param stranded 悬账退款(只服务「订单已取消 + 通道确认收款」):走窄口子断言,跳过核销状态判断。
     */
    private PreparedRefund prepare(String orderNo, OrderRefundRequest request, boolean stranded) {
        List<Map<String, Object>> orders = rows("""
                select o.id, o.order_no, o.talent_id, o.card_id, o.benefit_id, o.merchant_id, o.order_type,
                       o.pay_amount, o.order_status, o.verify_status,
                       p.payment_no, p.provider_transaction_no, p.created_at as payment_created_at,
                       coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id
                from benefit_order o
                left join payment_transaction p on p.order_id = o.id and p.provider = ? and p.status = 'SUCCEEDED'
                left join payment_transaction p on p.order_id = o.id and p.provider = ?
                     and (p.status = 'SUCCEEDED'
                          or (p.status = 'CLOSED'
                              and exists (select 1 from payment_reconciliation_issue i
                                          where i.issue_type = 'PAYMENT_ORDER_CANCELLED' and i.provider = p.provider
                                            and i.business_no = p.payment_no)))
                where o.deleted = 0 and o.order_no = ?
                order by p.id desc
                order by (p.status = 'SUCCEEDED') desc, p.id desc
                limit 1 for update
                """, PROVIDER, orderNo);
        if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "Order not found");
        Map<String, Object> order = orders.get(0);
        // 支付、核销、退款、提现锁定都按订单串行化:锁住订单行之后才判断能不能退款。
        OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(number(order.get("id")));
        locked.requireRefundable();
        if (locked.verifyStatus() == 1 && !"STORE_CONSUME".equals(text(order.get("orderType")))) {
            throw new BusinessException(ErrorCode.CONFLICT, "Verified orders cannot be refunded");
        if (stranded) {
            locked.requireStrandedRefundable();
        } else {
            locked.requireRefundable();
            if (locked.verifyStatus() == 1 && !"STORE_CONSUME".equals(text(order.get("orderType")))) {
                throw new BusinessException(ErrorCode.CONFLICT, "Verified orders cannot be refunded");
            }
        }
        Integer active = jdbc.queryForObject("select count(*) from order_refund where deleted = 0 and order_id = ? and refund_status in (1,2)", Integer.class, order.get("id"));
        if (active != null && active > 0) throw new BusinessException(ErrorCode.CONFLICT, "Refund has already been submitted for this order");
        // 部分退款:能不能退按金额算,而不是「有过退款记录就拒绝」。后者的副作用是首笔部分退款之后
        // 剩余金额永远退不了(审计 §14)。在途金额也要算进去,否则同一笔钱会被退两次。
        long paidAmount = number(order.get("payAmount"));
        long successfulRefund = refundedAmount(number(order.get("id")));
        long refundableAmount = Math.max(paidAmount - successfulRefund, 0);
        long pendingRefund = pendingRefundAmount(number(order.get("id")));
        long refundableAmount = Math.max(paidAmount - successfulRefund - pendingRefund, 0);
        long requestedAmount = MoneyUtils.yuanToCent(request.refundAmount());
        long refundAmount = requestedAmount > 0 ? requestedAmount : refundableAmount;
        if (refundAmount <= 0 || refundAmount > refundableAmount) throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid refund amount");
@@ -253,6 +321,12 @@
        return total == null ? 0 : total;
    }
    /** 已经发出去、还没有最终结果的退款金额:它同样不能再退一次。 */
    private long pendingRefundAmount(long orderId) {
        Long total = jdbc.queryForObject("select coalesce(sum(refund_amount), 0) from order_refund where deleted = 0 and refund_status = 1 and order_id = ?", Long.class, orderId);
        return total == null ? 0 : total;
    }
    /**
     * The merchant a refund must be requested from: the sub-merchant fixed on the payment or the
     * order. Payments made before the collecting sub-merchant was recorded were collected by the
talent-card-server/src/main/java/com/talentcard/server/business/payment/StrandedPaymentRefundRetry.java
New file
@@ -0,0 +1,107 @@
package com.talentcard.server.business.payment;
import java.util.List;
import java.util.Map;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.payment.HuifuRefundService.StrandedRefundOutcome;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
 * 悬账的自动出口:通道确认收到、但订单已经取消的钱,自动退回付款人。
 *
 * <p>这类钱是「回调没到达 + 用户后来又把预支付单付了」的产物:本地订单已取消,通道却把钱收下了。
 * 它只有一条正确出路——原路退回付款人,没有需要人判断的地方;留给人工只会让它一直挂在页面上。
 *
 * <p>消费的是 {@code PAYMENT_ORDER_CANCELLED} 问题记录(由结算服务在「取消后才确认收款」时写下):
 * 调窄口子退款入口,受理成功就把问题记录结掉;**通道明确拒绝**(例如商户基本户余额不足)则升级告警
 * 转人工,问题记录保持未结——绝不静默吞掉。
 *
 * <p>这不是新增「让状态能推进」的任务:钱的真相本来就由对账的查单带回来,这里只是把它落到退款上。
 */
@Component
public class StrandedPaymentRefundRetry {
    private static final Logger log = LoggerFactory.getLogger(StrandedPaymentRefundRetry.class);
    /** 结算服务为「取消后才确认收款」写下的问题类型。 */
    static final String ISSUE_ORDER_CANCELLED = "PAYMENT_ORDER_CANCELLED";
    /** 自动退款被通道拒绝时写下的问题类型:与上面那条分开,管理端一眼能看出「这笔要人工」。 */
    static final String ISSUE_REFUND_REJECTED = "STRANDED_REFUND_REJECTED";
    /** 每轮最多处理几笔,避免一次拉太多拖长任务。 */
    private static final int BATCH_SIZE = 20;
    private final JdbcTemplate jdbc;
    private final HuifuRefundService refundService;
    private final PaymentIssueRecorder issues;
    @Value("${app.payment.stranded-refund.enabled:true}")
    private boolean enabled = true;
    public StrandedPaymentRefundRetry(JdbcTemplate jdbc, HuifuRefundService refundService, PaymentIssueRecorder issues) {
        this.jdbc = jdbc;
        this.refundService = refundService;
        this.issues = issues;
    }
    /**
     * 定期把悬账退回付款人。默认 5 分钟一轮;用 {@code app.payment.stranded-refund.enabled} 关闭,
     * 用 {@code ...fixed-delay-ms} 调整频率。
     */
    @Scheduled(fixedDelayString = "${app.payment.stranded-refund.fixed-delay-ms:300000}")
    public void refundStrandedPayments() {
        if (!enabled) return;
        try {
            int refunded = retry(BATCH_SIZE);
            if (refunded > 0) log.info("Started {} stranded refund(s) for cancelled orders", refunded);
        } catch (RuntimeException exception) {
            log.warn("Stranded refund run failed: {}", exception.getClass().getSimpleName());
        }
    }
    /** 返回本轮真正发起过退款的笔数(供测试与运维使用)。 */
    public int retry(int limit) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select business_no, order_no, amount from payment_reconciliation_issue
                where issue_type = ? and provider = 'HUIFU' and status = 'open'
                  and order_no is not null and order_no <> ''
                order by detected_at asc, id asc
                limit ?
                """, ISSUE_ORDER_CANCELLED, Math.max(1, limit)));
        int refunded = 0;
        for (Map<String, Object> row : rows) {
            String paymentNo = text(row.get("businessNo"));
            String orderNo = text(row.get("orderNo"));
            long amount = number(row.get("amount"));
            StrandedRefundOutcome outcome = refundService.refundStranded(orderNo);
            switch (outcome) {
                case SUBMITTED -> {
                    // 退款已受理:这类钱有了唯一的出口,问题记录可以结了。退款自己的最终结果由退款
                    // 对账与读路径收敛(通道对退款也是先回「处理中」)。
                    issues.resolve(ISSUE_ORDER_CANCELLED, paymentNo, "system");
                    issues.resolve(ISSUE_REFUND_REJECTED, paymentNo, "system");
                    refunded++;
                    log.info("Stranded payment {} of cancelled order {} is being refunded to the payer", paymentNo, orderNo);
                }
                case REJECTED -> {
                    // 通道明确拒绝:钱退不回去,必须人工介入。问题记录保持未结,另记一条告警类型,
                    // 让管理端一眼看到「这笔悬账退不动」。
                    issues.issue(ISSUE_REFUND_REJECTED, paymentNo, orderNo, "refunded", "refund rejected", amount,
                            "The provider rejected the automatic refund of a cancelled order's payment; a human has to refund it");
                    log.error("Stranded payment {} of cancelled order {} could not be refunded automatically;"
                            + " the provider rejected the refund and it needs a human", paymentNo, orderNo);
                }
                default -> log.warn("Stranded payment {} of cancelled order {} has no refundable payment yet;"
                        + " the issue stays open for a human", paymentNo, orderNo);
            }
        }
        return refunded;
    }
    private long number(Object value) { return value instanceof Number number ? number.longValue() : 0; }
    private String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
}
talent-card-server/src/test/java/com/talentcard/server/business/payment/StrandedPaymentRefundTests.java
New file
@@ -0,0 +1,319 @@
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.when;
import java.math.BigDecimal;
import java.util.Map;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.order.OrderRefundRequest;
import com.talentcard.server.business.payment.HuifuRefundService.StrandedRefundOutcome;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
import com.talentcard.server.business.portal.UsageRecordPortalService;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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: money the provider collected for an order that is already cancelled has exactly one right
 * destination — back to the payer — and the refund has to be visible in local state.
 *
 * <p>The two provider seams (payment query and refund submission) are mocked; the reconciliation,
 * settlement, fund boundary and refund services all run for real against the shared MySQL schema.
 */
@SpringBootTest
@ActiveProfiles("test")
class StrandedPaymentRefundTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private PaymentReconciliationService reconciliationService;
    @Autowired
    private HuifuRefundService refundService;
    @Autowired
    private PaymentIssueRecorder issues;
    @Autowired
    private StrandedPaymentRefundRetry retry;
    @Autowired
    private UsageRecordPortalService usageRecordPortalService;
    @MockitoBean
    private HuifuMiniProgramPaymentGateway paymentGateway;
    @MockitoBean
    private HuifuRefundGateway refundGateway;
    private Long talentId;
    private Long merchantId;
    private String huifuId;
    private String suffix;
    private long sequence;
    @BeforeEach
    void prepareData() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "stranded-refund-openid-" + suffix, "Stranded refund user", "13900000005");
        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)",
                "Stranded refund merchant " + suffix, "08120000004", "Test address");
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8));
        sequence = 0;
    }
    @Test
    void aCancelledOrderWhosePaymentWasCollectedIsRefundedToThePayer() {
        String orderNo = cancelledOrderWithCollectedPayment("CANCEL_UNCONFIRMED", 2000L);
        providerAcceptsRefunds();
        int refunded = retry.retry(1);
        assertThat(refunded).as("这一轮应该发起一笔悬账退款").isEqualTo(1);
        assertThat(jdbc.queryForMap("select paid_amount, refund_amount, refund_status from order_refund where order_no = ?", orderNo))
                .as("悬账要真的退回付款人,而且是全额")
                .containsEntry("paid_amount", 2000L)
                .containsEntry("refund_amount", 2000L)
                .containsEntry("refund_status", 2);
        assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo(orderNo)))
                .as("钱有了唯一的出口,问题记录就该结掉")
                .isZero();
        assertThat(openIssueCount("STRANDED_REFUND_REJECTED", paymentNo(orderNo))).isZero();
        assertThat(((Number) talentOrderRecord(orderNo).get("refundStatus")).intValue())
                .as("人才端的「使用记录」要知道钱已经退回来了(页面渲染成「已退款」)")
                .isEqualTo(2);
    }
    @Test
    void aRefundTheProviderRejectsEscalatesToAHumanInsteadOfBeingSwallowed() {
        String orderNo = cancelledOrderWithCollectedPayment("CANCEL_UNCONFIRMED", 2000L);
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "F", "sub_resp_code", "20000001", "sub_resp_desc", "基本户余额不足")));
        retry.retry(1);
        assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo(orderNo)))
                .as("退不回去的悬账必须留在账上,绝不能静默吞掉")
                .isEqualTo(1L);
        assertThat(openIssueCount("STRANDED_REFUND_REJECTED", paymentNo(orderNo)))
                .as("通道拒绝要升级成一条能被人看见的告警")
                .isEqualTo(1L);
        assertThat(count("select count(*) from order_refund where order_no = ? and refund_status = 2", orderNo))
                .as("通道拒绝就是没有退成,不能记成已退款")
                .isZero();
    }
    /**
     * 本地已关闭、但通道确认收过钱的悬账:旧的支付键可见性只认已成功流水,这类钱连支付键都看不到,
     * 于是退款无从发起(D19 的回归)。
     */
    @Test
    void aPaymentClosedLocallyButConfirmedByTheProviderCanStillBeRefunded() {
        String orderNo = cancelledOrder("CLOSED", 2000L);
        String paymentNo = paymentNo(orderNo);
        issues.issue("PAYMENT_ORDER_CANCELLED", paymentNo, orderNo, "cancelled", "provider confirmed QUERY", 2000L,
                "Payment succeeded after the local order was cancelled");
        providerAcceptsRefunds();
        assertThat(refundService.refundStranded(orderNo)).isEqualTo(StrandedRefundOutcome.SUBMITTED);
        assertThat(jdbc.queryForMap("""
                select provider_original_req_seq_id, refund_amount from order_refund where order_no = ?
                """, orderNo))
                .as("退款必须定位到那笔已关闭的支付流水,否则通道侧无法原路退回")
                .containsEntry("provider_original_req_seq_id", paymentNo)
                .containsEntry("refund_amount", 2000L);
    }
    @Test
    void theNarrowEntryRefusesAnythingThatIsNotAStrandedPayment() {
        // 订单没取消:不能按悬账退款,否则会绕过普通退款路径的前置条件。
        String liveOrderNo = paidOrder(2000L, "SUCCEEDED");
        assertThat(refundService.refundStranded(liveOrderNo)).isEqualTo(StrandedRefundOutcome.NOT_APPLICABLE);
        // 订单已取消,但通道从没确认收过钱(流水还是未定局):没有可退的悬账。
        String undecidedNo = cancelledOrder("PREPAY_CREATED", 2000L);
        assertThat(refundService.refundStranded(undecidedNo)).isEqualTo(StrandedRefundOutcome.NOT_APPLICABLE);
        // 已经退过:不能重复退款。
        String refundingNo = cancelledOrder("CANCEL_UNCONFIRMED", 2000L);
        Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, refundingNo);
        jdbc.update("""
                insert into order_refund(refund_no, order_id, order_no, talent_id, merchant_id, order_type,
                                         paid_amount, refund_amount, refundable_amount, refund_status,
                                         provider, reason, operator_name)
                values (?, ?, ?, ?, ?, 'GROUP_BUY', 2000, 2000, 0, 1, 'HUIFU', '已受理', 'system')
                """, "R-EXISTING-" + suffix, orderId, refundingNo, talentId, merchantId);
        assertThat(refundService.refundStranded(refundingNo))
                .as("资金边界先锁订单行,再断言没有生效中的退款:在途退款不会被退第二次")
                .isEqualTo(StrandedRefundOutcome.NOT_APPLICABLE);
    }
    @Test
    void aPartialRefundLeavesTheRemainderRefundableWhileOverRefundingIsStillRefused() {
        String orderNo = paidOrder(10000L, "SUCCEEDED");
        providerAcceptsRefunds();
        refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("40.00"), "部分退款", "admin"));
        assertThat(jdbc.queryForMap("select refund_amount, refundable_amount, refund_status from order_refund where order_no = ?", orderNo))
                .containsEntry("refund_amount", 4000L)
                .containsEntry("refundable_amount", 6000L)
                .containsEntry("refund_status", 2);
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("70.00"), "超额", "admin")))
                .as("超额退款仍被拒")
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("Invalid refund amount");
        refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("60.00"), "退剩余", "admin"));
        assertThat(count("select coalesce(sum(refund_amount), 0) from order_refund where order_no = ? and refund_status = 2", orderNo))
                .as("首笔部分退款之后剩余金额必须还退得掉(旧规则下它永远退不了)")
                .isEqualTo(10000L);
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
                .as("全额退完订单才退出")
                .isEqualTo(2);
    }
    @Test
    void moneyStillInFlightCountsAgainstTheRefundableAmount() {
        String orderNo = paidOrder(10000L, "SUCCEEDED");
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "P", "resp_code", "00000100", "sub_resp_desc", "交易处理中")));
        refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("40.00"), "部分退款", "admin"));
        assertThat(jdbc.queryForObject("select refund_status from order_refund where order_no = ?", Integer.class, orderNo))
                .as("通道还在处理就停在退款中")
                .isEqualTo(1);
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("70.00"), "超额", "admin")))
                .as("在途金额也要算进可退额度,否则同一笔钱会被退两次")
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("Invalid refund amount");
        refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("60.00"), "退剩余", "admin"));
        assertThat(count("select count(*) from order_refund where order_no = ?", orderNo)).isEqualTo(2);
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("1.00"), "再多退一分", "admin")))
                .as("实付金额退满之后一分钱都不能再退")
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("Invalid refund amount");
    }
    /** 一笔「订单已取消 + 通道确认收款」的悬账,按真实链路构造:先取消,再由对账把收款事实落下来。 */
    private String cancelledOrderWithCollectedPayment(String paymentStatus, long amount) {
        String orderNo = cancelledOrder(paymentStatus, amount);
        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-STRANDED-" + suffix)));
        reconciliationService.confirmPendingPayments(orderNo);
        String paymentNo = paymentNo(orderNo);
        assertThat(openIssueCount("PAYMENT_ORDER_CANCELLED", paymentNo))
                .as("构造前提:对账发现了「取消后才收到钱」")
                .isEqualTo(1L);
        isolateIssueWindow(paymentNo);
        return orderNo;
    }
    /**
     * 消费者每轮按 {@code detected_at} 取最老的一批,而测试库是共享且不清理的:把本用例这条推到最老、
     * 把别的悬账推到窗口之外,这一轮就只会处理它自己(docs/agents/testing.md 的做法)。
     */
    private void isolateIssueWindow(String paymentNo) {
        jdbc.update("""
                update payment_reconciliation_issue set detected_at = now() + interval 30 day
                where issue_type = 'PAYMENT_ORDER_CANCELLED' and status = 'open' and business_no <> ?
                """, paymentNo);
        jdbc.update("""
                update payment_reconciliation_issue set detected_at = now() - interval 30 day
                where issue_type = 'PAYMENT_ORDER_CANCELLED' and business_no = ?
                """, paymentNo);
    }
    private String cancelledOrder(String paymentStatus, long amount) {
        String orderNo = "STRANDED-" + suffix + "-" + (++sequence);
        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, price_version, collect_huifu_id,
                                          product_name_snapshot)
                values (?, ?, ?, 'GROUP_BUY', ?, ?, ?, 2, 0, 1, ?, '悬账测试订单')
                """, orderNo, talentId, merchantId, amount, amount, amount, huifuId);
        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)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now() - interval 10 minute)
                """, paymentNo(orderNo), orderId, orderNo, talentId, merchantId, huifuId, amount, paymentStatus,
                "stranded-payment-" + suffix + "-" + sequence);
        return orderNo;
    }
    private String paidOrder(long amount, String paymentStatus) {
        String orderNo = "STRANDED-PAID-" + suffix + "-" + (++sequence);
        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, price_version, collect_huifu_id,
                                          product_name_snapshot)
                values (?, ?, ?, 'GROUP_BUY', ?, ?, ?, 1, 0, 1, ?, '部分退款测试订单')
                """, orderNo, talentId, merchantId, amount, amount, amount, huifuId);
        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, paid_at)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, ?, ?, ?, now())
                """, paymentNo(orderNo), orderId, orderNo, talentId, merchantId, huifuId, amount, paymentStatus,
                "paid-payment-" + suffix + "-" + sequence);
        return orderNo;
    }
    private String paymentNo(String orderNo) {
        return "P-" + orderNo;
    }
    private void providerAcceptsRefunds() {
        when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "S", "resp_code", "00000000", "sub_resp_desc", "交易成功", "hf_seq_id", "HF-STRANDED-R-" + suffix)));
    }
    private Map<String, Object> providerResponse(Map<String, Object> data) {
        return Map.of("data", data);
    }
    /** 人才端「使用记录」为该订单给出的那一条:页面按它的 refundStatus 渲染「已退款」。 */
    private Map<String, Object> talentOrderRecord(String orderNo) {
        PageResult<Map<String, Object>> page = usageRecordPortalService.list(talentId, null, 1, 50);
        return page.records().stream()
                .filter(record -> orderNo.equals(String.valueOf(record.get("businessId"))))
                .findFirst()
                .orElseThrow(() -> new AssertionError("Order " + orderNo + " is missing from the talent order list"));
    }
    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;
    }
}
talent-card-server/src/test/resources/application-test.yml
@@ -23,6 +23,10 @@
      # PendingPaymentConfirmationTests),避免后台任务在共享测试库上改动别的用例正在断言的流水与问题记录。
      reconciliation:
        enabled: false
    # 悬账自动退款的定时任务在测试里不触发:用例自己直接调用 retry(...),避免后台任务在共享测试库上
    # 改动别的用例正在断言的问题记录与退款记录。
    stranded-refund:
      enabled: false
  merchant:
    auto-withdrawal:
      # 自动提现的定时任务在测试里不触发:用例自己直接调用汇总/结果处理服务,避免后台任务在共享测试库上