yeg
2026-09-16 5c33cf1f2622d4470b861cb041024ead1775c828
feat(payment): 撤销核销把已确认的资金退回延迟户

核销成功时钱已经从延迟户划给商户(进基本户),撤销核销此前只翻库、不碰钱:订单回到「待核销」,
钱却留在商户手里,等 D1 自动结算到银行卡之后就再也追不回来。现在撤销核销走通道的【交易确认退款】
把已确认的金额退回延迟户,这笔钱才重新变成不可结算、不可取现。

- 只有回退成功(或钱本来就在延迟户)才允许翻单,其余情况一律停在「已核销」,并按原因给商户话:
已结算到银行卡 / 正在退回中 / 暂时无法发起 / 请人工核对
- 回退结果用一套独立分类,不复用交易确认的 classify:23000003 在确认路径上是幂等成功(有真实回执
复验过),在回退路径上含义未定,既不能算成功也不能算失败,停在 reverting 交人工
- 认领与落结果各走独立事务:通道调用不是事务性的,这些写入跟着外层事务回滚会把通道的真实结果丢掉
- 迁移 V147 记录回退的请求号/日期/返回码/金额/时间;上一轮确认的回执保留下来作为证据

同时修掉审计发现的三个同类隐患:

- 放款前在订单行锁内复查「订单仍处于已核销」:否则撤销核销之后,补偿任务或在途的确认请求会把钱
再打给商户,而订单已经回到待核销
- 退款不再把语义未定的业务码判成失败:判失败会解锁订单让用户再退一次,而通道那边第一次其实已经
出款时,第二次就是重复出款
- 确认被认领后进程中断(部署、崩溃)会留下 confirming 孤儿:补偿任务只挑得到它、从不重发,那笔钱
永远冻在延迟户;隔天收掉这种孤儿重新确认一次(重发是幂等的)

回退结果未知时没有自动收敛通道(【交易确认退款查询】的合同还没有真实回执验证过),停在 reverting
交人工——这是有意的保守选择:把失败当成功会让订单翻回待核销而钱还在商户基本户。
3个文件已添加
8个文件已修改
973 ■■■■■ 已修改文件
CONTEXT.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java 289 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V147__payment_fund_revert.sql 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java 241 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/RevokeVerificationFundTests.java 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuDelayRevertClassificationTests.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CONTEXT.md
@@ -17,6 +17,10 @@
核销成功时触发交易确认。资金确认状态记在 `payment_transaction.funds_confirmation_status`:
`NULL` 表示未走延迟入账,`pending` 表示钱还在延迟户,`confirmed` 表示已分账到商户基本户。
撤销核销必须走反方向:调用通道的**交易确认退款**把已确认的金额退回延迟户,状态回到 `pending`,
商户重新核销时会再确认一次。回退期间状态是 `reverting`(钱在哪一边未定),此时不允许撤销核销。
钱被 D1 结算划到商户银行卡之后就退不回来,撤销核销只能被拒绝——所以撤销窗口实际上止于下一次自动结算。
## 结算
出款由斗拱按结算周期自动完成,平台**不生成提现记录**,也不再发起主动取现。商户进件时通过
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -38,16 +38,19 @@
    private final OrderFundBoundaryService orderFundBoundary;
    private final com.talentcard.server.business.order.OrderVerificationService orderVerificationService;
    private final HuifuRefundService refundService;
    private final PaymentFundConfirmationService fundConfirmation;
    public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService,
            OrderFundBoundaryService orderFundBoundary,
            com.talentcard.server.business.order.OrderVerificationService orderVerificationService,
            HuifuRefundService refundService) {
            HuifuRefundService refundService,
            PaymentFundConfirmationService fundConfirmation) {
        this.jdbcTemplate = jdbcTemplate;
        this.manualConsumptionService = manualConsumptionService;
        this.orderFundBoundary = orderFundBoundary;
        this.orderVerificationService = orderVerificationService;
        this.refundService = refundService;
        this.fundConfirmation = fundConfirmation;
    }
    @Transactional(readOnly = true)
@@ -280,6 +283,13 @@
        if (verifyTime == null || Duration.between(verifyTime, LocalDateTime.now()).toHours() >= REVOCATION_HOURS) {
            throw new BusinessException(ErrorCode.CONFLICT, "已过可撤销时间");
        }
        // 核销成功时钱已经确认给了商户(基本户),撤销核销必须先把钱退回通道延迟户。回退没成功就不许
        // 翻单:否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销一次。
        String revertOutcome = fundConfirmation.revertForOrder(orderId);
        if (!PaymentFundConfirmationService.moneyBackInDelayAccount(revertOutcome)) {
            throw new BusinessException(ErrorCode.CONFLICT,
                    PaymentFundConfirmationService.revertRefusalMessage(revertOutcome));
        }
        String reason = request.reason() == null || request.reason().isBlank() ? "商户撤销核销" : request.reason().trim();
        int updated = jdbcTemplate.update("""
                update verify_record set status = 2, revoked_at = now(), revoked_by = ?, revoke_reason = ?, revoke_idempotency_key = ?
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
@@ -6,6 +6,7 @@
import com.talentcard.server.business.order.OrderFundBoundaryService;
import com.talentcard.server.business.order.OrderRefundRequest;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPayload;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.business.payment.huifu.HuifuRefundCommand;
import com.talentcard.server.business.payment.huifu.HuifuRefundGateway;
@@ -287,7 +288,10 @@
            String providerNo = textValue(data.get("hf_seq_id"));
            boolean codeSuccess = "00000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code);
            boolean success = codeSuccess && (status.isBlank() || "S".equalsIgnoreCase(status) || "SUCCESS".equalsIgnoreCase(status) || "PAID".equalsIgnoreCase(status));
            boolean failed = !success && !("P".equalsIgnoreCase(status) || "PROCESSING".equalsIgnoreCase(status) || "PENDING".equalsIgnoreCase(status));
            // 语义未定的码一律不判失败。判失败会把订单解锁成「可以再退一次」,万一通道那边第一次其实
            // 已经出款,第二次就是重复出款;停在在途最多是让对账/人工多收敛一轮。
            boolean failed = !success && !HuifuPayload.undeterminedCode(code)
                    && !("P".equalsIgnoreCase(status) || "PROCESSING".equalsIgnoreCase(status) || "PENDING".equalsIgnoreCase(status));
            return new ProviderResult(success, failed, code, message, providerNo);
        }
        private static String textValue(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java
@@ -9,12 +9,15 @@
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmCommand;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmRefundCommand;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmation;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayRevert;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
@@ -26,6 +29,9 @@
 * <p>状态记在 {@code payment_transaction.funds_confirmation_status}:{@code NULL} 表示这笔支付没有延迟
 * (支付即核销的到店消费),{@code pending} 表示钱还在通道冻着,{@code confirmed} 表示已经分配完成。
 * 自动提现只认 {@code NULL} 和 {@code confirmed},因此「忘了确认」不会变成「提前把钱提走」。
 *
 * <p>撤销核销走反方向({@link #revertForOrder}):调用交易确认退款把已确认的钱退回延迟户,成功后状态
 * 退回 {@code pending},商户重新核销时会再确认一次。回退期间用 {@code reverting} 占位。
 */
@Service
public class PaymentFundConfirmationService {
@@ -48,6 +54,33 @@
    public static final String FAILED = "FAILED";
    /** 找不到对应的支付记录。 */
    public static final String UNKNOWN = "UNKNOWN";
    /**
     * 订单此刻不再是「已核销」,不能把钱放给商户。
     *
     * <p>撤销核销会把订单翻回待核销,此时钱必须留在延迟户:确认在放款前复查这个前提,
     * 避免「订单已回到待核销、钱却已经打给商户」。
     */
    public static final String NOT_VERIFIED = "NOT_VERIFIED";
    /** 钱已退回(或本来就在)通道延迟户,撤销核销可以继续。 */
    public static final String REVERTED = "REVERTED";
    /** 这笔资金不需要回退:没有支付记录、支付没有走延迟入账,或钱本来就在延迟户。 */
    public static final String REVERT_NOT_NEEDED = "REVERT_NOT_NEEDED";
    /** 通道明确拒绝回退(例如资金已结算到银行卡、基本户余额不足),钱没有动。 */
    public static final String REVERT_REJECTED = "REVERT_REJECTED";
    /** 回退结果未定或仍在处理中:钱的位置不确定,撤销核销必须停在「已核销」。 */
    public static final String REVERT_IN_FLIGHT = "REVERT_IN_FLIGHT";
    /**
     * 返回码在这条路径上语义未定:通道给了话,但我们不知道它是什么意思,需要人工核对。
     *
     * <p>和 {@link #REVERT_IN_FLIGHT} 分开是因为给商户的话不同:在途可以说「稍后重试」,
     * 语义未定只能请他联系平台——它也可能是「已结算」,让商户反复重试是没有意义的。
     */
    public static final String REVERT_NEEDS_REVIEW = "REVERT_NEEDS_REVIEW";
    /** 回退请求根本没发出去(本地配置或 SDK 出错):钱没动,可以重试。 */
    public static final String REVERT_NOT_SENT = "REVERT_NOT_SENT";
    /** 找不到支付记录,或缺少定位原确认请求的键。 */
    public static final String REVERT_UNKNOWN = "REVERT_UNKNOWN";
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    private static final Logger log = LoggerFactory.getLogger(PaymentFundConfirmationService.class);
@@ -56,11 +89,24 @@
    private final TransactionTemplate transactions;
    private final HuifuDelayAccountGateway gateway;
    /**
     * 资金侧的写入用独立事务提交。
     *
     * <p>撤销核销会在自己的事务里调用本服务,而通道调用不是事务性的:认领和结果如果跟着外层事务一起
     * 回滚,通道那边已经退回延迟户的钱在本地就再也看不到了——状态还写着 {@code confirmed},钱其实已经
     * 不在商户基本户。用独立事务提交,判断依据就永远留在库里。
     */
    private final TransactionTemplate moneyTransactions;
    public PaymentFundConfirmationService(JdbcTemplate jdbc, TransactionTemplate transactions,
                                          HuifuDelayAccountGateway gateway) {
        this.jdbc = jdbc;
        this.transactions = transactions;
        this.gateway = gateway;
        // 注入的 TransactionTemplate 是容器里的单例,支付、退款和自动提现共用它,不能改它的传播行为,
        // 否则那些服务会跟着变成 REQUIRES_NEW、外层事务被静默挂起。这里自己新建一个。
        this.moneyTransactions = new TransactionTemplate(transactions.getTransactionManager());
        this.moneyTransactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    }
    /**
@@ -95,7 +141,9 @@
        }
        long paymentId = number(payment.get("id"));
        if (!claim(paymentId)) {
        ClaimOutcome claimOutcome = transactions.execute(status -> claimConfirmation(orderId, paymentId));
        if (claimOutcome == ClaimOutcome.ORDER_NOT_VERIFIED) return NOT_VERIFIED;
        if (claimOutcome != ClaimOutcome.CLAIMED) {
            // 另一个请求已经在确认这笔资金,或者它刚被确认为终态。
            return isConfirmed(currentStatus(paymentId)) ? CONFIRMED : PROCESSING;
        }
@@ -125,19 +173,243 @@
        return applyResult(paymentId, confirmNo, result);
    }
    /** 认领这笔资金确认:只有仍为 pending 的那一次能拿到,避免重复向通道发确认。 */
    /**
     * 撤销核销:把这笔已经确认给商户的资金退回通道延迟户。
     *
     * <p>调用方只有在拿到 {@link #REVERTED} 或 {@link #REVERT_NOT_NEEDED} 时才可以撤销核销。其余结果都
     * 表示钱的位置不确定、或仍在商户手里:此时翻单会造成「订单显示待核销、钱已经结算给商户」。
     *
     * <p>方法自身不开事务,认领与落结果各走独立事务(见 {@link #moneyTransactions}):通道调用不是
     * 事务性的,让这些写入跟着外层事务回滚,会把通道的真实结果丢掉。
     */
    public String revertForOrder(long orderId) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select p.id, p.payment_no, p.funds_confirmation_status, p.funds_confirm_no,
                       p.funds_confirm_request_date, p.funds_confirmed_amount, p.provider_huifu_id,
                       o.collect_huifu_id
                from payment_transaction p
                join benefit_order o on o.id = p.order_id
                where p.order_id = ?
                order by p.id desc
                limit 1
                """, orderId));
        // 没有支付记录:这笔订单在通道里没有钱(历史数据或手工数据),没有已确认的资金需要回退。
        if (rows.isEmpty()) return REVERT_NOT_NEEDED;
        Map<String, Object> payment = rows.get(0);
        String fundsStatus = text(payment.get("fundsConfirmationStatus"));
        // 没有走延迟入账(支付即核销的到店消费),或钱本来就在延迟户:同样不需要回退。
        if (fundsStatus.isBlank() || statusIs(fundsStatus, "pending")) return REVERT_NOT_NEEDED;
        // 回退在途或确认在途:钱在哪一边未定,等它收敛后再撤销。
        if (statusIs(fundsStatus, "reverting") || statusIs(fundsStatus, "confirming")) return REVERT_IN_FLIGHT;
        if (!statusIs(fundsStatus, "confirmed")) return REVERT_UNKNOWN;
        String confirmNo = text(payment.get("fundsConfirmNo"));
        String confirmRequestDate = text(payment.get("fundsConfirmRequestDate"));
        if (confirmNo.isBlank() || confirmRequestDate.isBlank()) {
            // 没有原确认的定位键就撤销不了确认;钱确实在商户手里,不能翻单,只能人工处理。
            log.warn("Payment {} is confirmed but has no confirmation reference, the revert needs a human",
                    payment.get("paymentNo"));
            return REVERT_UNKNOWN;
        }
        String huifuId = firstNonBlank(text(payment.get("providerHuifuId")), text(payment.get("collectHuifuId")));
        if (huifuId.isBlank()) {
            log.warn("Payment {} has no collecting sub-merchant, the revert needs a human", payment.get("paymentNo"));
            return REVERT_UNKNOWN;
        }
        long paymentId = number(payment.get("id"));
        String revertNo = BusinessIdGenerator.next("DR");
        String requestDate = REQUEST_DATE.format(LocalDate.now());
        if (!claimRevert(paymentId, revertNo, requestDate)) {
            // 另一个请求已经在回退这笔钱。
            return REVERT_IN_FLIGHT;
        }
        HuifuDelayRevert result;
        try {
            result = gateway.refund(new HuifuDelayConfirmRefundCommand(
                    revertNo, huifuId, confirmNo, confirmRequestDate, "撤销核销退回延迟户"));
        } catch (HuifuPaymentGatewayException exception) {
            // 请求可能已经被通道受理:停在 reverting 交给人工核对,绝不按失败处理,也绝不放行撤销核销。
            log.warn("Fund revert for payment {} has an unknown result: {}",
                    payment.get("paymentNo"), exception.getMessage());
            recordRevertAttempt(paymentId, revertNo, "", exception.getMessage());
            return REVERT_IN_FLIGHT;
        } catch (RuntimeException exception) {
            // 通道根本没被调到(配置不完整、SDK 初始化失败之类):钱没动,可以重试。不能说成「已结算」,
            // 商户照着这句话去找平台就白跑了。
            log.warn("Fund revert for payment {} failed before reaching the provider: {}",
                    payment.get("paymentNo"), exception.getMessage());
            abandonRevertClaim(paymentId, exception.getMessage());
            return REVERT_NOT_SENT;
        }
        return applyRevertResult(paymentId, revertNo, result, payment);
    }
    /**
     * 认领这笔资金确认:只有仍为 pending 的那一次能拿到,避免重复向通道发确认。
     *
     * <p>同时收掉「认领之后进程就没了」留下的孤儿:状态停在 {@code confirming}、认领日期又不是今天的
     * 那些请求,说明那次确认没走到落结果就中断了(部署、崩溃、超时被杀)。补偿任务只挑得到这种行而
     * 不会重发,没有这一步这笔钱就永远冻在延迟户里。重新确认是幂等的——通道会按「已无未确认金额」
     * 返回,{@link HuifuDelayConfirmation} 把那个码当成功,所以隔天补发一次是安全的。
     *
     * <p>认领日期为空的行不动:那说明它不是我们认领出来的(手工数据),宁可留着给人工看。
     */
    private boolean claim(long paymentId) {
        String today = REQUEST_DATE.format(LocalDate.now());
        int claimed = jdbc.update("""
                update payment_transaction
                set funds_confirmation_status = 'confirming', funds_confirm_request_date = ?
                where id = ? and funds_confirmation_status = 'pending'
                """, REQUEST_DATE.format(LocalDate.now()), paymentId);
                where id = ?
                  and (funds_confirmation_status = 'pending'
                       or (funds_confirmation_status = 'confirming'
                           and funds_confirm_request_date is not null
                           and funds_confirm_request_date < ?))
                """, today, paymentId, today);
        return claimed == 1;
    }
    /** 数据库里的确认状态是小写,比较必须大小写无关。 */
    /**
     * 在订单行锁里复查「订单此刻仍然已核销」,再认领资金确认。
     *
     * <p>撤销核销锁的是同一行,因此这个判断在整个认领期间是稳定的:撤销核销提交在前,这里会看到
     * {@code verify_status} 已经为 0 而拒绝认领;认领在前,撤销核销会看到 {@code confirming} 而拒绝翻单。
     * 两个方向都不会出现「订单回到待核销、钱却打给了商户」。
     */
    private ClaimOutcome claimConfirmation(long orderId, long paymentId) {
        List<Map<String, Object>> orders = DatabaseRows.normalize(jdbc.queryForList("""
                select verify_status, order_status, refund_status
                from benefit_order
                where id = ? and deleted = 0
                limit 1 for update
                """, orderId));
        if (orders.isEmpty()) return ClaimOutcome.ORDER_NOT_VERIFIED;
        Map<String, Object> order = orders.get(0);
        if (number(order.get("verifyStatus")) != 1 || number(order.get("orderStatus")) != 1
                || number(order.get("refundStatus")) != 0) {
            return ClaimOutcome.ORDER_NOT_VERIFIED;
        }
        return claim(paymentId) ? ClaimOutcome.CLAIMED : ClaimOutcome.NOT_CLAIMED;
    }
    private enum ClaimOutcome { CLAIMED, NOT_CLAIMED, ORDER_NOT_VERIFIED }
    /** 认领这笔资金回退:只有仍为 confirmed 的那一次能拿到,避免重复向通道发回退请求。 */
    private boolean claimRevert(long paymentId, String revertNo, String requestDate) {
        int claimed = moneyTransactions.execute(status -> jdbc.update("""
                update payment_transaction
                set funds_confirmation_status = 'reverting', funds_revert_no = ?,
                    funds_revert_request_date = ?, funds_revert_code = null, funds_revert_message = null
                where id = ? and funds_confirmation_status = 'confirmed'
                """, revertNo, requestDate, paymentId));
        return claimed == 1;
    }
    /**
     * 落回退结果。
     *
     * <p>只有通道明确说成功才把状态退回 {@code pending}(等于「钱在延迟户」)。明确拒绝退回
     * {@code confirmed},其余一律停在 {@code reverting}:这条路径上没有真实回执证明码的含义之前不猜,
     * 因为把失败当成功会让订单翻回待核销而钱还在商户基本户。
     */
    private String applyRevertResult(long paymentId, String revertNo, HuifuDelayRevert result,
                                     Map<String, Object> payment) {
        switch (result.outcome()) {
            case SUCCEEDED -> {
                // 上一轮的确认回执(funds_confirm_*)保留下来作为「曾经确认过」的证据;商户重新核销时
                // 会走一次全新的交易确认。
                Long revertedAmount = cents(payment.get("fundsConfirmedAmount"));
                moneyTransactions.executeWithoutResult(status -> jdbc.update("""
                        update payment_transaction
                        set funds_confirmation_status = 'pending', funds_revert_no = ?,
                            funds_revert_code = ?, funds_revert_message = ?,
                            funds_reverted_amount = ?, funds_reverted_at = now()
                        where id = ? and funds_confirmation_status = 'reverting'
                        """, revertNo, result.code(), result.describe(), revertedAmount, paymentId));
                log.info("Fund revert {} succeeded: the money is back in the delay account", revertNo);
                return REVERTED;
            }
            case FAILED -> {
                // 通道明确拒绝:钱没有动,状态退回可重试的 confirmed,并把回执留作证据。
                moneyTransactions.executeWithoutResult(status -> jdbc.update("""
                        update payment_transaction
                        set funds_confirmation_status = 'confirmed',
                            funds_revert_code = ?, funds_revert_message = ?
                        where id = ? and funds_confirmation_status = 'reverting'
                        """, result.code(), result.describe(), paymentId));
                log.warn("Fund revert {} was rejected: {} ({})", revertNo, result.describe(), result.code());
                return REVERT_REJECTED;
            }
            case NEEDS_REVIEW -> {
                // 通道给了话但语义未定(例如 23000003):不能假定钱回到了延迟户,留在 reverting 等人工。
                // 通道流水号要进日志:人工去控台核对时靠它定位这笔回退。
                recordRevertAttempt(paymentId, revertNo, result.code(), result.describe());
                log.warn("Fund revert {} has an undetermined result, the order stays verified:"
                                + " provider={} code={} desc={}",
                        revertNo, result.providerTransactionNo(), result.code(), result.describe());
                return REVERT_NEEDS_REVIEW;
            }
            default -> {
                // 通道还在处理:同样是钱的位置未定,但它是正常的在途,商户稍后重试就能拿到结果。
                recordRevertAttempt(paymentId, revertNo, result.code(), result.describe());
                return REVERT_IN_FLIGHT;
            }
        }
    }
    /** 只记录一次回退尝试的请求号与描述,不改状态。 */
    private void recordRevertAttempt(long paymentId, String revertNo, String code, String message) {
        moneyTransactions.executeWithoutResult(status -> jdbc.update("""
                update payment_transaction
                set funds_revert_no = ?, funds_revert_code = ?, funds_revert_message = ?
                where id = ?
                """, revertNo, code, message == null ? "" : message, paymentId));
    }
    /**
     * 放弃这次回退认领:请求根本没发出去(本地配置或 SDK 出错),钱没有动,退回 confirmed 让商户重试。
     *
     * <p>请求号与请求日期要清掉:它们是给通道侧定位用的,这次通道根本没收到。失败原因留在
     * {@code funds_revert_code/message} 里,免得排查时只看到一条干干净净的 confirmed。
     */
    private void abandonRevertClaim(long paymentId, String message) {
        moneyTransactions.executeWithoutResult(status -> jdbc.update("""
                update payment_transaction
                set funds_confirmation_status = 'confirmed', funds_revert_no = null,
                    funds_revert_request_date = null, funds_revert_code = 'LOCAL_ERROR',
                    funds_revert_message = ?
                where id = ? and funds_confirmation_status = 'reverting'
                """, message == null ? "" : message, paymentId));
    }
    /** 这笔资金是否确实在通道延迟户——撤销核销只有在这个前提下才允许翻单。 */
    public static boolean moneyBackInDelayAccount(String outcome) {
        return REVERTED.equals(outcome) || REVERT_NOT_NEEDED.equals(outcome);
    }
    /**
     * 回退没有成功时给商户的话。
     *
     * <p>不能只说「撤销失败」:商户需要分清是钱已经结算到银行卡(要联系平台),还是通道还在处理
     * (稍后重试就行),否则只会反复点。文案跟结论常量放在一起,免得将来新增一种结论时漏配。
     */
    public static String revertRefusalMessage(String outcome) {
        if (REVERT_REJECTED.equals(outcome)) return "这笔订单的资金已结算到商户银行卡,无法撤销核销";
        if (REVERT_IN_FLIGHT.equals(outcome)) return "这笔订单的资金正在退回中,请稍后重试";
        if (REVERT_NOT_SENT.equals(outcome)) return "撤销核销暂时无法发起,请稍后重试";
        return "这笔订单的资金状态无法核对,请联系平台处理";
    }
    /** 数据库里的状态是小写,比较必须大小写无关。 */
    private static boolean statusIs(String status, String expected) {
        return status != null && expected.equalsIgnoreCase(status.trim());
    }
    private static boolean isConfirmed(String status) {
        return status != null && "confirmed".equalsIgnoreCase(status.trim());
        return statusIs(status, "confirmed");
    }
    private String currentStatus(long paymentId) {
@@ -196,6 +468,11 @@
        return value instanceof Number number ? number.longValue() : 0L;
    }
    /** 库里的金额字段(分);空值保持 null,好和「没记过金额」区分开。 */
    private static Long cents(Object value) {
        return value instanceof Number number ? number.longValue() : null;
    }
    private LocalDate localDate(Object value) {
        if (value instanceof java.sql.Timestamp timestamp) return timestamp.toLocalDateTime().toLocalDate();
        if (value instanceof java.time.LocalDateTime dateTime) return dateTime.toLocalDate();
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java
@@ -24,6 +24,9 @@
 *
 * <p>业务上是否延迟由订单类型决定:先付钱后核销的订单延迟,核销成功才确认;支付即核销的订单不延迟,
 * 因此不需要确认。
 *
 * <p>核销被撤销时走反方向:交易确认退款把已确认给商户的钱退回延迟户,重新变成不可结算、不可取现,
 * 等商户重新核销时再确认一次。这条路径的结果分类与确认分开,见 {@link HuifuDelayRevert}。
 */
@Service
public class HuifuDelayAccountGateway {
@@ -81,8 +84,8 @@
        return execute(request, CONFIRM_QUERY_FUNCTION, "汇付交易确认查询失败");
    }
    /** 把已确认的金额退回原商户账户。 */
    public HuifuDelayConfirmation refund(HuifuDelayConfirmRefundCommand command) {
    /** 把已确认的金额退回原商户账户(延迟户),撤销核销用它把钱要回来。 */
    public HuifuDelayRevert refund(HuifuDelayConfirmRefundCommand command) {
        ensureConfigured();
        initializeSdk();
        Map<String, Object> request = new LinkedHashMap<>();
@@ -96,10 +99,10 @@
                "req_seq_id", command.refundNo(),
                "huifu_id", command.huifuId().trim(),
                "org_req_seq_id", command.confirmNo())));
        return execute(request, CONFIRM_REFUND_FUNCTION, "汇付交易确认退款失败");
        return executeRevert(request, CONFIRM_REFUND_FUNCTION, "汇付交易确认退款失败");
    }
    private HuifuDelayConfirmation execute(Map<String, Object> request, String function, String failureMessage) {
    private Map<String, Object> call(Map<String, Object> request, String function, String failureMessage) {
        Map<String, Object> response;
        try {
            response = BasePayClient.request(request, function, false);
@@ -109,7 +112,15 @@
            throw new HuifuPaymentGatewayException(failureMessage, isOutcomeUnknown(exception), exception);
        }
        if (response == null) throw new HuifuPaymentGatewayException(failureMessage, true, null);
        return HuifuDelayConfirmation.from(response);
        return response;
    }
    private HuifuDelayConfirmation execute(Map<String, Object> request, String function, String failureMessage) {
        return HuifuDelayConfirmation.from(call(request, function, failureMessage));
    }
    private HuifuDelayRevert executeRevert(Map<String, Object> request, String function, String failureMessage) {
        return HuifuDelayRevert.from(call(request, function, failureMessage));
    }
    private void ensureConfigured() {
@@ -239,4 +250,65 @@
            return HuifuPayload.amountInCent(confirmedAmount);
        }
    }
    /**
     * 交易确认退款(撤销核销回退资金)的结果。
     *
     * <p>刻意不复用 {@link HuifuDelayConfirmation} 的分类:同一个业务码在确认和回退两条路径上语义
     * 不同。{@code 23000003} 在确认路径上是「已无未确认金额」——那是既有真实回执复验过的幂等成功,
     * 而在回退路径上它既可能是「已无已确认金额」(同样是幂等),也可能是被拒的原因(例如基本户
     * 余额不足)。这里一律不猜:判成成功会让订单翻回「待核销」而钱还在商户基本户,是资金事故;
     * 判成失败又没有回执依据。所以语义未定的码单独归到 {@link Outcome#NEEDS_REVIEW},停在
     * 「已核销」等人工核对。
     */
    public record HuifuDelayRevert(
            Outcome outcome,
            String code,
            String message,
            String providerTransactionNo
    ) {
        public enum Outcome {
            /** 钱已退回商户延迟户。 */
            SUCCEEDED,
            /** 通道还在处理,结果未定。 */
            PROCESSING,
            /** 通道明确拒绝(例如资金已结算到银行卡、余额不足),钱没有动。 */
            FAILED,
            /** 返回码在这条路径上语义未定,不能当作成功或失败,需要人工核对。 */
            NEEDS_REVIEW
        }
        static HuifuDelayRevert from(Map<String, Object> response) {
            Map<String, Object> data = HuifuPayload.businessData(response);
            String code = HuifuPayload.code(data);
            String message = HuifuPayload.firstNonBlank(
                    HuifuPayload.value(data, "sub_resp_desc", "trans_desc"), HuifuPayload.message(data));
            String status = HuifuPayload.value(data, "trans_stat", "trans_status", "transStat", "transStatus");
            String providerNo = HuifuPayload.value(data, "hf_seq_id", "org_hf_seq_id", "hfSeqId", "orgHfSeqId");
            return new HuifuDelayRevert(classify(status, code), code, message, providerNo);
        }
        /**
         * 在回退路径上语义未定的业务码见 {@link HuifuPayload#undeterminedCode(String)}。
         *
         * <p>它们必须排在交易状态之前判断:通道给这些码时会同时给 {@code trans_stat=F},
         * 先看状态就会把它们当成明确失败(交易确认那条路上正是这样踩过一次坑)。
         */
        private static Outcome classify(String status, String code) {
            String normalizedCode = code == null ? "" : code.trim();
            if (HuifuPayload.undeterminedCode(normalizedCode)) return Outcome.NEEDS_REVIEW;
            if (HuifuPayload.successCode(normalizedCode)) return Outcome.SUCCEEDED;
            if (HuifuPayload.status(status) == HuifuPayload.TransStatus.FAILED) return Outcome.FAILED;
            if ("00000100".equals(normalizedCode)) return Outcome.PROCESSING;
            return normalizedCode.isBlank() ? Outcome.PROCESSING : Outcome.FAILED;
        }
        /** 供日志与故障定位使用的一句话结论。 */
        public String describe() {
            if (!message.isBlank()) return message;
            if (!code.isBlank()) return "Huifu delay confirmation refund returned code " + code;
            return "The provider returned no description";
        }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java
@@ -60,6 +60,22 @@
        return "00000000".equals(value) || "10000".equals(value) || "0".equals(value) || "SUCCESS".equalsIgnoreCase(value);
    }
    /**
     * 在延时交易的资金路径上语义未定的业务码:既不能当作成功,也不能当作失败。
     *
     * <p>同一个码按接口和场景重载。{@code 23000003} 在交易确认页写作「确认金额大于可确认金额」或
     * 「已无未确认金额」;{@code 23000004} 在同一页写作「需要传入分账串」。交易确认那条路已经用真实
     * 回执验证过「{@code 23000003} 同时带 {@code trans_stat=F}」实际是幂等成功,说明只看交易状态
     * 会判错;而它在退款、交易确认退款方向上的含义还没有回执验证过。
     *
     * <p>因此遇到这些码的下游一律不要下结论,两个方向的错都很贵:判成功等于放行可能没动的钱,
     * 判失败等于让本地以为钱没动、于是再动一次(重复出款)。
     */
    public static boolean undeterminedCode(String code) {
        String value = text(code);
        return "23000003".equals(value) || "23000004".equals(value);
    }
    /** Provider amount in yuan with two decimals, converted to integer cents. Null when unusable. */
    public static Long amountInCent(String yuan) {
        String value = text(yuan);
talent-card-server/src/main/resources/db/migration/V147__payment_fund_revert.sql
New file
@@ -0,0 +1,70 @@
-- 撤销核销的资金回退。
--
-- 核销会把延迟入账的钱从通道「待确认」确认给商户(钱进商户基本户)。撤销核销必须把钱要回来,
-- 靠的是通道的【交易确认退款】接口:把已确认的金额退回商户的延迟户,重新变成不可结算、不可取现。
--
-- 回退期间 funds_confirmation_status 用 'reverting' 占位:
--   'pending'    钱在通道延迟户(从未确认过,或已回退成功)
--   'confirming' 已发出交易确认请求,结果未定
--   'confirmed'  确认成功,钱在商户基本户
--   'reverting'  已发出交易确认退款请求,结果未定——既不能当作钱在基本户,也不能当作钱已回到
--                延迟户,因此撤销核销必须停在「已核销」等它收敛
--
-- 回退失败(例如钱已被 D1 结算划到商户银行卡、基本户余额不足)会把状态退回 'confirmed',
-- 并把返回码与描述留在下面这几列里作为证据;回退成功后退回 'pending',商户重新核销时会走一次
-- 全新的交易确认,因此上一轮的确认回执(funds_confirm_*)要保留下来,不要清空。
--
-- 用存储过程按 exists 判断逐列追加,使脚本可以重复执行(本地开发的库常常手工加过列)。
DROP PROCEDURE IF EXISTS add_fund_revert_columns;
DELIMITER //
CREATE PROCEDURE add_fund_revert_columns()
BEGIN
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_revert_no') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_revert_no VARCHAR(64) DEFAULT NULL COMMENT '交易确认退款(撤销核销回退)请求流水号';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_revert_request_date') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_revert_request_date CHAR(8) DEFAULT NULL COMMENT '交易确认退款请求日期(yyyyMMdd)';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_revert_code') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_revert_code VARCHAR(64) DEFAULT NULL COMMENT '交易确认退款返回码';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_revert_message') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_revert_message VARCHAR(512) DEFAULT NULL COMMENT '交易确认退款返回描述';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_reverted_amount') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_reverted_amount BIGINT DEFAULT NULL COMMENT '回退的金额(分),取自上一轮确认回执的已确认金额';
    END IF;
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                     AND column_name = 'funds_reverted_at') THEN
        ALTER TABLE payment_transaction
            ADD COLUMN funds_reverted_at DATETIME DEFAULT NULL COMMENT '资金退回延迟户的时间';
    END IF;
    -- 状态列多了一个 'reverting':注释只在还是旧文案时刷新一次。
    IF EXISTS (SELECT 1 FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'payment_transaction'
                 AND column_name = 'funds_confirmation_status'
                 AND column_comment LIKE '%confirmed,NULL 表示%') THEN
        ALTER TABLE payment_transaction
            MODIFY COLUMN funds_confirmation_status VARCHAR(16) DEFAULT NULL
                COMMENT '延迟交易的资金确认状态:pending/confirming/confirmed/reverting,NULL 表示未走延迟入账';
    END IF;
END //
DELIMITER ;
CALL add_fund_revert_columns();
DROP PROCEDURE add_fund_revert_columns;
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java
@@ -2,12 +2,15 @@
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;
@@ -16,6 +19,8 @@
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;
@@ -108,6 +113,7 @@
    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"));
@@ -128,6 +134,7 @@
    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));
@@ -141,6 +148,7 @@
    void aConfirmationAlreadyInFlightIsNotStartedAgain() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        insertPaidPayment(orderNo, 100L, "confirming");
        markVerified(orderId(orderNo));
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.PROCESSING);
@@ -169,12 +177,17 @@
    @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));
        String paymentNo = insertPaidPayment(orderNo, 100L, "confirmed");
        markVerified(orderId(orderNo));
        // 补偿任务扫的是整库,会顺带处理其它用例留下的「已核销 + 待确认」订单,所以给它们一个可用的
        // 回执,并把断言收窄到本单:要证明的是这笔已经确认过的钱不会被再确认一次。
        when(delayGateway.confirm(any())).thenReturn(processing());
        confirmationRetry.retry(50);
        verify(delayGateway, never()).confirm(any());
        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
@@ -204,6 +217,7 @@
    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(
@@ -232,6 +246,161 @@
        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<String, Object> 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", "账户余额不足");
    }
    /**
     * 返回码语义未定时绝不能算成功。
     *
     * <p>{@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 初始化失败)时,钱没有动,可以重试。
     *
     * <p>不能报成「已结算到银行卡」:那是另一回事,商户照着这句话去找平台就白跑了。
     */
    @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<String, Object> 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");
    }
    /**
     * 放款前必须复查订单仍是「已核销」:撤销核销会把订单翻回待核销,钱必须留在延迟户。
     *
     * <p>没有这道复查,补偿任务(或在途的确认请求)会在撤销之后把钱打给商户,而订单已经回到待核销。
     */
    @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
    // ------------------------------------------------------------------------------------------
@@ -242,6 +411,37 @@
                "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);
    }
    /**
     * 造一笔「延迟入账 + 资金已确认给商户」的订单,返回支付流水号。
     *
     * <p>确认走真实服务而不是直接改字段:撤销时用来定位原确认的请求号与请求日期,正是它写下的。
     */
    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);
    }
    /**
     * 通道说「已无未确认金额」表示这笔延迟交易此前已经确认过(重新确认不会重复分账)。
     * 必须按成功处理:否则补偿任务会一直认为没确认,每隔几分钟无意义地打一次通道。
@@ -250,6 +450,7 @@
    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", "已无未确认金额", "", "", ""));
@@ -260,6 +461,40 @@
                + " 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());
    }
    /** 通道还在处理:状态保持 pending,供确认补偿任务下一轮再试。 */
    private HuifuDelayAccountGateway.HuifuDelayConfirmation processing() {
        return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java
@@ -112,6 +112,28 @@
                .isEqualTo(1);
    }
    /**
     * 语义未定的业务码不能判成失败:判失败会把订单解锁,用户再退一次就成了重复出款。
     *
     * <p>{@code 23000003} 在交易确认那条路上已经用真实回执验证过会伴随 {@code trans_stat=F},
     * 所以在退款这条路上也只能停在「退款中」,等通道查单/对账/人工给答案。
     */
    @Test
    void anUndeterminedRefundCodeDoesNotUnlockTheOrderForAnotherRefund() {
        RefundingOrder order = refundingOrder(1);
        when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of(
                "trans_stat", "F", "sub_resp_code", "23000003", "sub_resp_desc", "已无未确认金额")));
        recordOf(order.orderNo());
        assertThat(jdbc.queryForObject("select refund_status from order_refund where refund_no = ?", Integer.class, order.refundNo()))
                .as("语义未定的码既不能判成功也不能判失败")
                .isEqualTo(1);
        assertThat(jdbc.queryForObject("select refund_status from benefit_order where order_no = ?", Integer.class, order.orderNo()))
                .as("订单不能被解锁去再退一次")
                .isEqualTo(1);
    }
    @Test
    void anAlreadyRefundedOrderIsNotWaitingToBeVerifiedAndAsksTheProviderNothing() {
        RefundingOrder order = refundingOrder(2);
talent-card-server/src/test/java/com/talentcard/server/business/payment/RevokeVerificationFundTests.java
New file
@@ -0,0 +1,160 @@
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.time.LocalDateTime;
import java.util.Map;
import com.talentcard.server.business.merchant.MerchantRevokeVerificationRequest;
import com.talentcard.server.business.merchant.MerchantWorkService;
import com.talentcard.server.business.merchant.VerifyRequest;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway;
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;
/**
 * 撤销核销的资金侧:钱必须真的回到通道延迟户,订单才允许翻回「待核销」。
 *
 * <p>核销时钱已经从延迟户划给商户(进了基本户),撤销核销要靠通道的交易确认退款把它退回来。
 * 回退没成功就不许翻单——否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销一次;
 * 等 D1 自动结算把钱划到商户银行卡之后,通道那边也退不回来了。
 */
@SpringBootTest
@ActiveProfiles("test")
class RevokeVerificationFundTests {
    private static final String HUIFU_ID = "6666000226048687";
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantWorkService merchantWorkService;
    @MockitoBean
    private HuifuDelayAccountGateway delayGateway;
    private Long merchantId;
    private Long storeId;
    private Long talentId;
    private MerchantPrincipal principal;
    @BeforeEach
    void prepareMerchant() {
        String suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into merchant(merchant_name, status, can_login_merchant_app, deleted) values (?, 1, 1, 0)",
                "撤销核销商户 " + suffix);
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        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 talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
                "revoke-fund-" + suffix, "撤销核销用户", "13900000010");
        talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
        principal = new MerchantPrincipal(9101L, merchantId, storeId, "operator");
    }
    /** 撤销核销要把钱退回延迟户:退回成功才翻单,这笔钱才算重新变成不可结算。 */
    @Test
    void revokesOnlyAfterTheMoneyComesBackToTheDelayAccount() {
        long orderId = verifiedOrderWithConfirmedMoney();
        when(delayGateway.refund(any())).thenReturn(revert(HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED,
                "00000000", "交易成功"));
        Map<String, Object> revoked = merchantWorkService.revokeVerification(principal,
                new MerchantRevokeVerificationRequest(String.valueOf(orderId), "test reversal"), "revoke-back-" + orderId);
        assertThat(revoked).containsEntry("orderStatus", "pending_verification");
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId))
                .isZero();
        // 钱回到延迟户,商户下次核销时会走一次全新的交易确认。
        assertThat(fundsStatus(orderId)).isEqualTo("pending");
    }
    /**
     * 通道没把钱退回来就不许翻单:否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销。
     */
    @Test
    void refusesToRevokeWhileTheMoneyHasNotComeBack() {
        long orderId = verifiedOrderWithConfirmedMoney();
        when(delayGateway.refund(any())).thenReturn(revert(
                HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.NEEDS_REVIEW, "23000003", "已无已确认金额"));
        assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal,
                new MerchantRevokeVerificationRequest(String.valueOf(orderId), "test reversal"), "revoke-held-" + orderId))
                .isInstanceOf(BusinessException.class)
                // 语义未定时不能报成「已结算」,也不能让商户反复重试:请他联系平台。
                .hasMessageContaining("无法核对");
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId))
                .isEqualTo(1);
        // 钱的位置没定:状态停在 reverting,等人工核对,不能当成已经退回延迟户。
        assertThat(fundsStatus(orderId)).isEqualTo("reverting");
    }
    /** 钱已经结算到银行卡时,商户要知道原因,而不是只看到一句「撤销失败」。 */
    @Test
    void tellsTheMerchantWhenTheSettledMoneyCannotBeCalledBack() {
        long orderId = verifiedOrderWithConfirmedMoney();
        when(delayGateway.refund(any())).thenReturn(revert(
                HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.FAILED, "22000003", "账户余额不足"));
        assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal,
                new MerchantRevokeVerificationRequest(String.valueOf(orderId), "test reversal"), "revoke-settled-" + orderId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("已结算到商户银行卡");
        assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId))
                .isEqualTo(1);
        // 明确拒绝:钱没有动,状态回到可重试的 confirmed,商户补足后还能再撤。
        assertThat(fundsStatus(orderId)).isEqualTo("confirmed");
    }
    private HuifuDelayAccountGateway.HuifuDelayRevert revert(
            HuifuDelayAccountGateway.HuifuDelayRevert.Outcome outcome, String code, String message) {
        return new HuifuDelayAccountGateway.HuifuDelayRevert(outcome, code, message, "HF-R-" + System.nanoTime());
    }
    /**
     * 造一笔「已核销 + 资金已确认给商户」的延迟入账订单,返回订单主键。
     *
     * <p>核销走真实服务:它会写下核销记录并调用交易确认,撤销时才有核销记录可撤、有确认可退。
     */
    private long verifiedOrderWithConfirmedMoney() {
        String orderNo = "ORDER-REVOKE-" + System.nanoTime();
        jdbc.update("""
                insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, order_amount,
                                          subsidy_amount, pay_amount, order_status, verify_status, created_at, deleted)
                values (?, ?, ?, ?, 'GROUP_BUY', 10000, 1000, 9000, 1, 0, ?, 0)
                """, orderNo, talentId, merchantId, storeId, java.sql.Timestamp.valueOf(LocalDateTime.now()));
        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, attempt_no,
                                                paid_at, funds_confirmation_status)
                values (?, 'HUIFU', ?, ?, ?, ?, ?, 9000, 'SUCCEEDED', ?, 1, now(), 'pending')
                """, "P-VERIFY-" + System.nanoTime(), orderId, orderNo, talentId, merchantId, HUIFU_ID,
                "verify-payment-" + orderNo);
        when(delayGateway.confirm(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayConfirmation(
                HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED,
                "00000000", "交易成功", "0.00", "90.00", "HF-C-" + System.nanoTime()));
        merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "test verification"),
                "verify-" + orderNo);
        return orderId;
    }
    private String fundsStatus(long orderId) {
        return jdbc.queryForObject("select funds_confirmation_status from payment_transaction where order_id = ?",
                String.class, orderId);
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuDelayRevertClassificationTests.java
New file
@@ -0,0 +1,71 @@
package com.talentcard.server.business.payment.huifu;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.LinkedHashMap;
import java.util.Map;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayRevert;
import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayRevert.Outcome;
import org.junit.jupiter.api.Test;
/**
 * 资金回退(交易确认退款)的结果分类。
 *
 * <p>这一组用例盯的是「同一个业务码在两条路径上语义不同」:交易确认把 {@code 23000003} 当作
 * 「已无未确认金额」=幂等成功(有真实回执复验过),但回退路径上没有回执证明它的含义,一律不能算成功
 * ——判成成功会让订单翻回「待核销」而钱还在商户基本户,属于资金事故。
 */
class HuifuDelayRevertClassificationTests {
    @Test
    void aSuccessCodeFromTheProviderIsTheOnlySuccess() {
        assertThat(outcomeOf("00000000", "S")).isEqualTo(Outcome.SUCCEEDED);
        // 顶层网关码也代表成功调用。
        assertThat(outcomeOf("10000", "S")).isEqualTo(Outcome.SUCCEEDED);
    }
    /**
     * 语义未定的码既不算成功也不算失败:{@code 23000003} 在确认路径上是幂等成功,
     * 在回退路径上可能是「已无已确认金额」,也可能是被拒的原因(例如余额不足)。
     */
    @Test
    void codesWithUndeterminedMeaningAreNeverSuccess() {
        assertThat(outcomeOf("23000003", "F")).isEqualTo(Outcome.NEEDS_REVIEW);
        assertThat(outcomeOf("23000004", "F")).isEqualTo(Outcome.NEEDS_REVIEW);
        assertThat(outcomeOf("23000003", "S")).isEqualTo(Outcome.NEEDS_REVIEW);
    }
    @Test
    void aDeclaredFailureIsAFailure() {
        assertThat(outcomeOf("22000002", "F")).isEqualTo(Outcome.FAILED);
        assertThat(outcomeOf("", "F")).isEqualTo(Outcome.FAILED);
    }
    @Test
    void aRequestStillInFlightIsNotASettledResult() {
        assertThat(outcomeOf("00000100", "P")).isEqualTo(Outcome.PROCESSING);
        assertThat(outcomeOf("", "")).isEqualTo(Outcome.PROCESSING);
        // 状态说成功但没有任何业务码:两个证据不一致,按未定处理,不按成功放行。
        assertThat(outcomeOf("", "S")).isEqualTo(Outcome.PROCESSING);
    }
    @Test
    void theProviderDescriptionIsKeptForTroubleshooting() {
        HuifuDelayRevert revert = HuifuDelayRevert.from(response("22000003", "F", "账户余额不足"));
        assertThat(revert.describe()).isEqualTo("账户余额不足");
        assertThat(revert.code()).isEqualTo("22000003");
    }
    private Outcome outcomeOf(String code, String transStat) {
        return HuifuDelayRevert.from(response(code, transStat, "返回描述")).outcome();
    }
    private Map<String, Object> response(String code, String transStat, String description) {
        Map<String, Object> response = new LinkedHashMap<>();
        response.put("sub_resp_code", code);
        response.put("sub_resp_desc", description);
        response.put("trans_stat", transStat);
        return response;
    }
}