package com.talentcard.server.business.payment; import com.talentcard.server.business.BusinessIdGenerator; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.MoneyUtils; 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; 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; import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @Service public class HuifuRefundService { 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; private final HuifuRefundGateway gateway; private final HuifuPaymentProperties huifuPaymentProperties; private final OrderFundBoundaryService orderFundBoundary; public HuifuRefundService(JdbcTemplate jdbc, TransactionTemplate transactions, HuifuRefundGateway gateway, HuifuPaymentProperties huifuPaymentProperties, OrderFundBoundaryService orderFundBoundary) { this.jdbc = jdbc; this.transactions = transactions; this.gateway = gateway; this.huifuPaymentProperties = huifuPaymentProperties; this.orderFundBoundary = orderFundBoundary; } public Map 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); } /** * 悬账自动退款:通道确认收到、但订单已经取消的钱,原路退回付款人。 * *

这是**窄口子**入口,只服务一种情形({@code requireStrandedRefundable}):订单已取消、通道确认 * 收过钱、还没退、也没有退款记录。它有唯一正确的出路(退回付款人),没有需要人判断的地方;留人工只会 * 让它一直挂在页面上。它**不绕过**资金边界:仍然先锁订单行,因此与撤销核销、交易确认、普通退款互相 * 串行化,不会互踩。 * *

它不返回通道的最终结论——退款本身也要等通道出结果,那由退款对账与读路径收敛。这里只回答 * 「有没有把退款发出去」,因为调用方(定时消费者)据此决定是结掉问题记录还是升级告警。 */ 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 submit(PreparedRefund prepared) { Map response; try { response = gateway.createRefund(new HuifuRefundCommand( prepared.refundNo(), prepared.huifuId(), prepared.paymentNo(), prepared.originalProviderNo(), prepared.originalRequestDate(), prepared.refundAmount())); } catch (HuifuPaymentGatewayException exception) { if (!exception.isOutcomeUnknown()) { markFailed(prepared.refundNo(), prepared.orderId(), "GATEWAY_REJECTED", exception.getMessage()); throw new BusinessException(ErrorCode.CONFLICT, "Refund request was rejected"); } return detail(prepared.refundNo()); } catch (BusinessException exception) { markFailed(prepared.refundNo(), prepared.orderId(), "LOCAL_ERROR", exception.getMessage()); throw exception; } catch (RuntimeException exception) { return detail(prepared.refundNo()); } ProviderResult result = ProviderResult.from(response); if (result.success()) { markSucceeded(prepared, result); } else if (result.failed()) { markFailed(prepared.refundNo(), prepared.orderId(), result.code(), result.message()); throw new BusinessException(ErrorCode.CONFLICT, "Refund request was rejected"); } else { updateProviderResult(prepared.refundNo(), result); } return detail(prepared.refundNo()); } public Map query(String refundNo) { Map refund = detail(refundNo); if (number(refund.get("refundStatus")) != 1) return refund; HuifuRefundCommand command = new HuifuRefundCommand( refundNo, refundHuifuId(refund.get("providerHuifuId")), text(refund.get("providerOriginalReqSeqId")), text(refund.get("providerOriginalNo")), text(refund.get("providerOriginalReqDate")), number(refund.get("refundAmount"))); try { ProviderResult result = ProviderResult.from(gateway.queryRefund(command, text(refund.get("providerRefundNo")))); if (result.success()) markSucceeded(new PreparedRefund(refundNo, number(refund.get("orderId")), number(refund.get("refundAmount"))), result); else if (result.failed()) markFailed(refundNo, number(refund.get("orderId")), result.code(), result.message()); else updateProviderResult(refundNo, result); } catch (HuifuPaymentGatewayException ignored) { // Keep the refund pending when the provider outcome is unknown. } return detail(refundNo); } /** 刚提交的退款通道必然还在处理,留一点时间再去问,避免用户一打开页面就打通道。 */ private static final int SUBMISSION_GRACE_SECONDS = 20; /** 一轮最多收敛几笔在途退款:正常用户同一时刻只有一笔,列表读路径不该被它拖长。 */ public static final int PENDING_CONFIRM_LIMIT = 2; /** * 把这位人才名下还在途的退款各自向通道确认一次。 * *

退款提交时通道多半只回「处理中」,最终结果只有通道知道;退款通知又会遇到和支付通知一样的 * 问题——本地或内网环境收不到。于是在读订单/读订单列表之前问一次,把「退款中」尽快收敛成通道的 * 最终答案,而不是等对账任务的下一个窗口(最长十分钟)。已经落定的退款连查都不查。 * *

在自己的事务里执行,且必须是调用方事务里的**第一条**语句:调用方的事务要能读到收敛后的状态, * 而 MySQL 的一致性快照是在该事务的第一条读语句上定下的。单笔查询失败只跳过这一笔——读路径上的 * 尽力收敛,查不到结果不该让订单列表打不开,剩下的交给对账任务和下一次读。 */ @Transactional(propagation = Propagation.REQUIRES_NEW) public int confirmPendingRefunds(Long talentId, int limit) { return confirmPendingRefunds("o.talent_id", talentId, limit); } /** 同上,按商户归属收敛:商户端订单列表读之前调用,免得商户看到的退款状态比人才端晚几分钟。 */ @Transactional(propagation = Propagation.REQUIRES_NEW) public int confirmPendingRefundsOfMerchant(Long merchantId, int limit) { return confirmPendingRefunds("o.merchant_id", merchantId, limit); } private int confirmPendingRefunds(String ownerColumn, Long ownerId, int limit) { if (ownerId == null) return 0; List> pending = rows(""" select r.refund_no from order_refund r join benefit_order o on o.id = r.order_id where r.deleted = 0 and r.provider = ? and r.refund_status = 1 and o.deleted = 0 and %s = ? and r.created_at <= date_sub(now(), interval ? second) order by r.id desc limit ? """.formatted(ownerColumn), PROVIDER, ownerId, SUBMISSION_GRACE_SECONDS, Math.max(1, limit)); for (Map row : pending) { try { query(text(row.get("refundNo"))); } catch (RuntimeException ignored) { // 这一笔这次问不到,保持在途;对账任务与下一次读会再来。 } } return pending.size(); } public Map detail(String refundNo) { List> rows = rows(""" select r.id, r.order_id, r.refund_no, r.order_no, r.order_type, r.paid_amount, r.refund_amount, r.refundable_amount, r.refund_status, r.provider, r.provider_refund_no, r.provider_huifu_id, r.provider_original_no, r.provider_original_req_seq_id, r.provider_original_req_date, r.provider_response_code, r.provider_response_message, r.reason, r.operator_name, r.refund_time from order_refund r where r.deleted = 0 and r.refund_no = ? """, refundNo); if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "Refund record not found"); return rows.get(0); } /** * 无实付金额订单的回执:它不是通道说的,是本地结清时自己写的。 * *

写进 {@code provider_response_*} 是为了让这条记录自解释——事后看到「退款成功、金额 0、通道退款号为空」 * 时,能直接从这两列读出「它从来没有出过本地」,不必再去通道上找一笔不存在的退款。 */ private static final ProviderResult LOCAL_REFUND_RESULT = new ProviderResult( true, false, "LOCAL_NO_PAYABLE_AMOUNT", "订单无实付金额(由权益免费额度覆盖),本地退款,无需通道退款", ""); /** * 准备一笔退款:锁定订单行、确认这笔退款合法、算出可退金额、落一条退款记录。 * *

「支付键可见性」不是只看已成功流水:本地已关闭、但通道确认收过钱的那一类悬账 * ({@code CLOSED} + 一条「取消后才收到钱」的问题记录)也必须能被定位到原支付流水, * 否则连退款都发起不了——而这类钱的唯一出路就是退回付款人。 * * @param stranded 悬账退款(只服务「订单已取消 + 通道确认收款」):走窄口子断言,跳过核销状态判断。 */ private PreparedRefund prepare(String orderNo, OrderRefundRequest request, boolean stranded) { List> 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' 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.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 order = orders.get(0); // 支付、核销、退款、提现锁定都按订单串行化:锁住订单行之后才判断能不能退款。 OrderFundBoundaryService.LockedOrder locked = orderFundBoundary.lock(number(order.get("id"))); 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"); } } // 部分退款:能不能退按金额算,而不是「有过退款记录就拒绝」。后者的副作用是首笔部分退款之后 // 剩余金额永远退不了(审计 §14)。在途金额也要算进去,否则同一笔钱会被退两次。 long paidAmount = number(order.get("payAmount")); long successfulRefund = refundedAmount(number(order.get("id"))); 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; // 没有实付金额的订单(整单被权益免费额度覆盖、下单即 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 (!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 = 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, provider, provider_huifu_id, provider_original_no, provider_original_req_seq_id, provider_original_req_date, reason, operator_name) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?) """, 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, settledLocally ? null : PROVIDER, huifuId, providerNo, paymentNo, requestDate, request.reason(), request.operatorName()); // 退款受理成功即刻生效:在结果确定前订单不再可核销,也不参与提现汇总。 orderFundBoundary.markRefundInProgress(number(order.get("id"))); 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) { transactions.executeWithoutResult(status -> { // 通道退款流水号只在发起退款的那次回执里有;退款查询的回执不带它。这里必须保留已有的号, // 否则查单确认会把唯一的通道凭证覆盖成空,退款排查和对账就失去定位依据。 jdbc.update(""" update order_refund set refund_status = 2, provider_refund_no = coalesce(nullif(?, ''), provider_refund_no), provider_response_code = ?, provider_response_message = ?, refund_time = now() where refund_no = ? and refund_status = 1 """, result.providerNo(), result.code(), result.message(), prepared.refundNo()); jdbc.update(""" update benefit_order o set o.order_status = 2 where o.id = ? and o.order_status = 1 and o.pay_amount <= (select coalesce(sum(r.refund_amount), 0) from order_refund r where r.order_id = o.id and r.refund_status = 2 and r.deleted = 0) """, prepared.orderId()); orderFundBoundary.markRefunded(prepared.orderId()); }); } /** 退款失败没有资金变动,订单回到未退款状态(可再次核销或再次退款)。 */ private void markFailed(String refundNo, long orderId, String code, String message) { transactions.executeWithoutResult(status -> { int failed = jdbc.update("update order_refund set refund_status = 3, provider_response_code = ?, provider_response_message = ? where refund_no = ? and refund_status = 1", code, message, refundNo); // 只有本事务真的把这笔退款置为失败,才把订单退回未退款状态;否则会覆盖并发的退款成功。 if (failed == 1) orderFundBoundary.markRefundFailed(orderId); }); } private void updateProviderResult(String refundNo, ProviderResult result) { jdbc.update("update order_refund set provider_refund_no = coalesce(?, provider_refund_no), provider_response_code = ?, provider_response_message = ? where refund_no = ? and refund_status = 1", result.providerNo(), result.code(), result.message(), refundNo); } private long refundedAmount(long orderId) { Long total = jdbc.queryForObject("select coalesce(sum(refund_amount), 0) from order_refund where deleted = 0 and refund_status = 2 and order_id = ?", Long.class, orderId); 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 * configured platform merchant, so those fall back to it instead of the refund failing. */ private String refundHuifuId(Object storedHuifuId) { String resolved = text(storedHuifuId); return resolved.isBlank() ? text(huifuPaymentProperties.getMerchantId()) : resolved; } private String requestDate(Object value) { if (value instanceof LocalDateTime dateTime) return REQUEST_DATE.format(dateTime); if (value instanceof LocalDate date) return date.format(DateTimeFormatter.BASIC_ISO_DATE); return REQUEST_DATE.format(LocalDateTime.now()); } private List> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); } 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, 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) { private static ProviderResult from(Map response) { Map data = response.get("data") instanceof Map map ? (Map) map : response; String status = textValue(data.get("trans_stat")); String code = textValue(data.get("sub_resp_code")); if (code.isBlank()) code = textValue(data.get("resp_code")); String message = textValue(data.get("sub_resp_desc")); if (message.isBlank()) message = textValue(data.get("resp_desc")); 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 && !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(); } } }