yeg
2026-09-18 0df97e3090a94493c1d8d9cd2909916d343c96e6
fix(payment): 无实付金额的订单改走本地退款,不再向通道发起

0 元订单(整单被权益免费额度覆盖、下单即待核销)里没有钱,通道侧也没有原交易可指,
退款却按通道退款处理,卡在金额闸门上抛 Invalid refund amount:用户从「待使用」里出不去,
这一次免费额度也永远占着。开发库里有 12 单停在「有退款按钮、点了必定报错」的状态。

根因是退款把「能退的东西」等同于「通道收过的钱」。改为 paid_amount = 0 时本地结清:
不发通道请求,通道相关列留空并写一条自解释回执(LOCAL_NO_PAYABLE_AMOUNT),在同一事务里
让订单退出待核销,被占用的免费额度随之释放;不留「退款中」,因为那个状态的含义是等通道的
结论,而这单没有通道那一侧。悬账退款不受影响,仍然只走通道。

回归测试走真实链路(配了免费额度的商品 → 人才端下单 → 使用记录点退款),断言额度确实回来,
并用 verifyNoInteractions 锁住「从头到尾没碰过通道」;另有一条边界用例保证 0 元单上显式要求
退非零金额仍然被拒。修复前该用例报的正是用户那句 Invalid refund amount。
1个文件已添加
1个文件已修改
230 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/ZeroAmountRefundTests.java 191 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
@@ -52,6 +52,8 @@
    public Map<String, Object> request(String orderNo, OrderRefundRequest request) {
        PreparedRefund prepared = transactions.execute(status -> prepare(orderNo, request, false));
        if (prepared == null) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "Refund initialization failed");
        // 本地结清的退款在 prepare 的事务里就已经落定,没有通道答复要等(也没有通道可以问)。
        if (prepared.settledLocally()) return detail(prepared.refundNo());
        return submit(prepared);
    }
@@ -214,6 +216,15 @@
    }
    /**
     * 无实付金额订单的回执:它不是通道说的,是本地结清时自己写的。
     *
     * <p>写进 {@code provider_response_*} 是为了让这条记录自解释——事后看到「退款成功、金额 0、通道退款号为空」
     * 时,能直接从这两列读出「它从来没有出过本地」,不必再去通道上找一笔不存在的退款。
     */
    private static final ProviderResult LOCAL_REFUND_RESULT = new ProviderResult(
            true, false, "LOCAL_NO_PAYABLE_AMOUNT", "订单无实付金额(由权益免费额度覆盖),本地退款,无需通道退款", "");
    /**
     * 准备一笔退款:锁定订单行、确认这笔退款合法、算出可退金额、落一条退款记录。
     *
     * <p>「支付键可见性」不是只看已成功流水:本地已关闭、但通道确认收过钱的那一类悬账
@@ -260,14 +271,19 @@
        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");
        // 没有实付金额的订单(整单被权益免费额度覆盖、下单即 order_status=1 且从来没有支付尝试)里没有钱,
        // 通道那边也没有原交易可指。它仍然必须能退——要还回去的是那一次被占用的免费额度——所以它的出路是
        // 本地结清,而不是向通道发起退款:发起也只会得到「金额非法」,用户从此卡在待使用里出不去。
        // 悬账退款不在此列:悬账退的是通道真收过的钱,必须走通道。
        boolean settledLocally = !stranded && paidAmount <= 0 && refundAmount == 0;
        if (!settledLocally && (refundAmount <= 0 || refundAmount > refundableAmount)) throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid refund amount");
        String paymentNo = text(order.get("paymentNo"));
        String providerNo = text(order.get("providerTransactionNo"));
        if (paymentNo.isBlank() && providerNo.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "Payment transaction is not ready for refund");
        String huifuId = refundHuifuId(order.get("huifuId"));
        if (!settledLocally && paymentNo.isBlank() && providerNo.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "Payment transaction is not ready for refund");
        String huifuId = settledLocally ? null : refundHuifuId(order.get("huifuId"));
        String refundNo = BusinessIdGenerator.next("R");
        String requestDate = requestDate(order.get("paymentCreatedAt"));
        String requestDate = settledLocally ? null : requestDate(order.get("paymentCreatedAt"));
        jdbc.update("""
                insert into order_refund(refund_no, order_id, order_no, talent_id, card_id, benefit_id, merchant_id,
                                         order_type, paid_amount, refund_amount, refundable_amount, refund_status,
@@ -277,10 +293,17 @@
                """,
                refundNo, order.get("id"), order.get("orderNo"), order.get("talentId"), order.get("cardId"),
                order.get("benefitId"), order.get("merchantId"), order.get("orderType"), paidAmount, refundAmount,
                refundableAmount - refundAmount, PROVIDER, huifuId, providerNo, paymentNo, requestDate, request.reason(), request.operatorName());
                refundableAmount - refundAmount, settledLocally ? null : PROVIDER, huifuId, providerNo, paymentNo,
                requestDate, request.reason(), request.operatorName());
        // 退款受理成功即刻生效:在结果确定前订单不再可核销,也不参与提现汇总。
        orderFundBoundary.markRefundInProgress(number(order.get("id")));
        return new PreparedRefund(refundNo, number(order.get("id")), huifuId, paymentNo, providerNo, requestDate, refundAmount);
        PreparedRefund result = new PreparedRefund(refundNo, number(order.get("id")), huifuId, paymentNo, providerNo, requestDate, refundAmount, settledLocally);
        if (settledLocally) {
            // 与 prepare 同一事务里当场结清,不把订单留在「退款中」:那个状态的含义是等通道的结论,而这
            // 单没有通道那一侧,留在这里就永远等不到(退款对账只扫 provider = HUIFU 的在途行)。
            markSucceeded(result, LOCAL_REFUND_RESULT);
        }
        return result;
    }
    private void markSucceeded(PreparedRefund prepared, ProviderResult result) {
@@ -347,8 +370,8 @@
    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(); }
    private record PreparedRefund(String refundNo, long orderId, String huifuId, String paymentNo, String originalProviderNo, String originalRequestDate, long refundAmount) {
        private PreparedRefund(String refundNo, long orderId, long refundAmount) { this(refundNo, orderId, "", "", "", "", refundAmount); }
    private record PreparedRefund(String refundNo, long orderId, String huifuId, String paymentNo, String originalProviderNo, String originalRequestDate, long refundAmount, boolean settledLocally) {
        private PreparedRefund(String refundNo, long orderId, long refundAmount) { this(refundNo, orderId, "", "", "", "", refundAmount, false); }
    }
    private record ProviderResult(boolean success, boolean failed, String code, String message, String providerNo) {
talent-card-server/src/test/java/com/talentcard/server/business/payment/ZeroAmountRefundTests.java
New file
@@ -0,0 +1,191 @@
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.Mockito.verifyNoInteractions;
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.huifu.HuifuRefundGateway;
import com.talentcard.server.business.portal.AppOrderCreateRequest;
import com.talentcard.server.business.portal.AppOrderPortalService;
import com.talentcard.server.business.portal.PendingUsePortalService;
import com.talentcard.server.business.portal.PendingUseRefundRequest;
import com.talentcard.server.business.portal.PortalService;
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: 一张没有实付金额的订单(整单被权益免费额度覆盖)到底能不能退。
 *
 * <p>它没有钱在通道里——下单不经过支付,订单出生就是待核销状态——所以要退的是那一次被占用的免费额度,
 * 而不是钱。这条用例走真实链路:商品配好免费额度 → 人才端下单(价格被算成 0)→ 使用记录里点退款,
 * 断言额度确实回来了,并且从头到尾没有向通道发起过退款(那张单在通道侧根本不存在)。
 */
@SpringBootTest
@ActiveProfiles("test")
class ZeroAmountRefundTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private AppOrderPortalService orderPortalService;
    @Autowired
    private PendingUsePortalService pendingUsePortalService;
    @Autowired
    private PortalService portalService;
    @Autowired
    private UsageRecordPortalService usageRecordPortalService;
    @Autowired
    private HuifuRefundService refundService;
    @MockitoBean
    private HuifuRefundGateway refundGateway;
    private Long talentId;
    private Long merchantId;
    private Long productId;
    private String suffix;
    @BeforeEach
    void prepareData() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "zero-refund-openid-" + suffix, "0元订单用户", "13900000007");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        jdbc.update("""
                insert into talent_card(talent_id, card_no, card_level, card_type, status, deleted)
                values (?, ?, '卓越人才卡', 'PERSONAL', 1, 0)
                """, talentId, "ZC" + suffix);
        jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)",
                "0元订单商户 " + suffix, "08120000006", "Test address");
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        // 免费权益:这个卡等级每单免费、周期内 1 次。resetCycle = NONE 表示不重置,额度窗口取全部历史,
        // 于是「用掉没还回来」在测试里是长期可见的,不受运行日期影响。
        jdbc.update("""
                insert into merchant_product(merchant_id, product_no, product_type, product_name, original_price,
                                             sale_price, stock, audit_status, status, rights_config, deleted)
                values (?, ?, 'GROUP_BUY', '0元订单测试商品', 20.00, 20.00, -1, 2, 1, ?, 0)
                """, merchantId, "ZERO-PRODUCT-" + suffix,
                "[{\"level\":\"卓越人才卡\",\"enabled\":true,\"free\":true,\"limitCount\":1,\"resetCycle\":\"NONE\"}]");
        productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class);
    }
    @Test
    void refundingAZeroAmountOrderGivesTheFreeQuotaBackWithoutTouchingTheProvider() {
        assertThat(talentPrice()).as("构造前提:额度在手,这个商品对这位人才是免费的").isEqualByComparingTo("0");
        String orderNo = zeroAmountOrder();
        assertThat(talentPrice()).as("下单即占用额度(0 元单没有支付这一步,出生就是待核销)").isEqualByComparingTo("20.00");
        Map<String, Object> refund = pendingUsePortalService.refund(orderNo, new PendingUseRefundRequest(talentId, "用户申请退款"));
        // 这笔单在通道侧从来不存在,退款不该、也没法向通道发起:发起只会得到「金额非法」。
        verifyNoInteractions(refundGateway);
        assertThat(((Number) refund.get("refundStatus")).intValue())
                .as("接口回给人才端的退款状态:已经退成了,不是「退款中」")
                .isEqualTo(2);
        assertThat(jdbc.queryForMap("""
                select paid_amount, refund_amount, provider, provider_refund_no, provider_response_code
                from order_refund where order_no = ?
                """, orderNo))
                .as("这条退款记录要自解释:没有实付金额、没有通道退款号,说明它从来没有出过本地")
                .containsEntry("paid_amount", 0L)
                .containsEntry("refund_amount", 0L)
                .containsEntry("provider", null)
                .containsEntry("provider_refund_no", null)
                .containsEntry("provider_response_code", "LOCAL_NO_PAYABLE_AMOUNT");
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
                .as("订单退出待核销,人才端不再为它显示核销码")
                .isEqualTo(2);
        assertThat(jdbc.queryForObject("select refund_status from benefit_order where order_no = ?", Integer.class, orderNo))
                .isEqualTo(2);
        // 免费额度是按「pay_amount = 0 且 order_status = 1」计数的(AppOrderPortalService 与 PortalService 的
        // usedFreeCount),所以订单退出待核销就是额度还回去了。这里直接问价格,看的是用户实际会看到的结果。
        assertThat(talentPrice())
                .as("这一次免费额度必须还回去,否则用户这个周期内再也买不到这个商品")
                .isEqualByComparingTo("0");
        assertThat(usageRecord(orderNo))
                .as("「使用记录」是人才端唯一看得见退款结果的地方")
                .containsEntry("status", "cancelled");
        assertThat(((Number) usageRecord(orderNo).get("refundStatus")).intValue())
                .as("页面按它渲染「已退款」")
                .isEqualTo(2);
        assertThatThrownBy(() -> pendingUsePortalService.refund(orderNo, new PendingUseRefundRequest(talentId, "再退一次")))
                .as("重复点击不能再结清一次")
                .isInstanceOf(BusinessException.class);
        assertThat(count("select count(*) from order_refund where order_no = ?", orderNo))
                .as("本地结清只发生一次")
                .isEqualTo(1L);
    }
    @Test
    void anExplicitAmountOnAZeroAmountOrderIsStillRefused() {
        String orderNo = zeroAmountOrder();
        assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("5.00"), "超额", "admin")))
                .as("没有实付金额不等于可以凭空退钱:本地结清只回答「无实付金额」这一种情形")
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("Invalid refund amount");
        assertThat(count("select count(*) from order_refund where order_no = ?", orderNo)).isZero();
        assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, orderNo))
                .as("被拒的退款不能改动订单状态")
                .isEqualTo(1);
    }
    /** 人才端下单。商品配了免费额度,所以这一步算出来的就是 0 元订单:没有支付,直接待核销。 */
    private String zeroAmountOrder() {
        orderPortalService.create(talentId, new AppOrderCreateRequest(merchantId, productId, null), "zero-amount-refund-" + suffix);
        String orderNo = jdbc.queryForObject("""
                select order_no from benefit_order where deleted = 0 and talent_id = ? and product_id = ?
                order by id desc limit 1
                """, String.class, talentId, productId);
        assertThat(jdbc.queryForMap("select pay_amount, order_status, expires_at from benefit_order where order_no = ?", orderNo))
                .as("构造前提:这是一张 0 元订单,出生即待核销,也没有支付时限")
                .containsEntry("pay_amount", 0L)
                .containsEntry("order_status", 1)
                .containsEntry("expires_at", null);
        assertThat(count("select count(*) from payment_transaction where order_no = ?", orderNo))
                .as("构造前提:通道侧从来没有这笔钱")
                .isZero();
        return orderNo;
    }
    /** 这位人才现在在这个商品上看到的价格:0 表示免费额度还有,20.00 表示这个周期的额度已经被占用了。 */
    private BigDecimal talentPrice() {
        return portalService.products(merchantId, null, talentId, 1, 20).records().stream()
                .filter(product -> productId.equals(((Number) product.get("id")).longValue()))
                .map(product -> (BigDecimal) product.get("salePrice"))
                .findFirst()
                .orElseThrow(() -> new AssertionError("Product " + productId + " is missing from the product list"));
    }
    private Map<String, Object> usageRecord(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 usage records"));
    }
    private long count(String sql, Object... args) {
        Long value = jdbc.queryForObject(sql, Long.class, args);
        return value == null ? 0 : value;
    }
}