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<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);
|
}
|
|
/**
|
* 悬账自动退款:通道确认收到、但订单已经取消的钱,原路退回付款人。
|
*
|
* <p>这是**窄口子**入口,只服务一种情形({@code requireStrandedRefundable}):订单已取消、通道确认
|
* 收过钱、还没退、也没有退款记录。它有唯一正确的出路(退回付款人),没有需要人判断的地方;留人工只会
|
* 让它一直挂在页面上。它**不绕过**资金边界:仍然先锁订单行,因此与撤销核销、交易确认、普通退款互相
|
* 串行化,不会互踩。
|
*
|
* <p>它不返回通道的最终结论——退款本身也要等通道出结果,那由退款对账与读路径收敛。这里只回答
|
* 「有没有把退款发出去」,因为调用方(定时消费者)据此决定是结掉问题记录还是升级告警。
|
*/
|
public StrandedRefundOutcome refundStranded(String orderNo) {
|
PreparedRefund prepared;
|
try {
|
prepared = transactions.execute(status -> prepare(orderNo, new OrderRefundRequest(
|
BigDecimal.ZERO, "订单取消后通道确认收款,自动退回付款人", "system"), true));
|
} catch (BusinessException exception) {
|
// 这条窄口子不适用(订单没取消、支付没有通道确认的成功事实、已经退过):不是通道拒绝,
|
// 不该升级告警,交回调用方按自己的规则处理。
|
return StrandedRefundOutcome.NOT_APPLICABLE;
|
}
|
if (prepared == null) return StrandedRefundOutcome.NOT_APPLICABLE;
|
try {
|
submit(prepared);
|
return StrandedRefundOutcome.SUBMITTED;
|
} catch (BusinessException exception) {
|
// 通道明确拒绝(例如商户基本户余额不足):必须转人工,绝不能静默吞掉。
|
log.warn("Stranded refund {} for order {} was rejected: {}",
|
prepared.refundNo(), orderNo, exception.getMessage());
|
return StrandedRefundOutcome.REJECTED;
|
}
|
}
|
|
/** 悬账自动退款的结论。 */
|
public enum StrandedRefundOutcome {
|
/** 退款请求已被通道受理:最终结果由退款对账与读路径收敛。 */
|
SUBMITTED,
|
/** 通道明确拒绝:要升级告警转人工。 */
|
REJECTED,
|
/** 没有可自动退的悬账:不需要处理。 */
|
NOT_APPLICABLE
|
}
|
|
/** 把一笔已准备好的退款发给通道,并记录通道的答复。 */
|
private Map<String, Object> submit(PreparedRefund prepared) {
|
Map<String, Object> response;
|
try {
|
response = gateway.createRefund(new HuifuRefundCommand(
|
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<String, Object> query(String refundNo) {
|
Map<String, Object> 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;
|
|
/**
|
* 把这位人才名下还在途的退款各自向通道确认一次。
|
*
|
* <p>退款提交时通道多半只回「处理中」,最终结果只有通道知道;退款通知又会遇到和支付通知一样的
|
* 问题——本地或内网环境收不到。于是在读订单/读订单列表之前问一次,把「退款中」尽快收敛成通道的
|
* 最终答案,而不是等对账任务的下一个窗口(最长十分钟)。已经落定的退款连查都不查。
|
*
|
* <p>在自己的事务里执行,且必须是调用方事务里的**第一条**语句:调用方的事务要能读到收敛后的状态,
|
* 而 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<Map<String, Object>> 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<String, Object> row : pending) {
|
try {
|
query(text(row.get("refundNo")));
|
} catch (RuntimeException ignored) {
|
// 这一笔这次问不到,保持在途;对账任务与下一次读会再来。
|
}
|
}
|
return pending.size();
|
}
|
|
public Map<String, Object> detail(String refundNo) {
|
List<Map<String, Object>> 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);
|
}
|
|
/**
|
* 无实付金额订单的回执:它不是通道说的,是本地结清时自己写的。
|
*
|
* <p>写进 {@code provider_response_*} 是为了让这条记录自解释——事后看到「退款成功、金额 0、通道退款号为空」
|
* 时,能直接从这两列读出「它从来没有出过本地」,不必再去通道上找一笔不存在的退款。
|
*/
|
private static final ProviderResult LOCAL_REFUND_RESULT = new ProviderResult(
|
true, false, "LOCAL_NO_PAYABLE_AMOUNT", "订单无实付金额(由权益免费额度覆盖),本地退款,无需通道退款", "");
|
|
/**
|
* 准备一笔退款:锁定订单行、确认这笔退款合法、算出可退金额、落一条退款记录。
|
*
|
* <p>「支付键可见性」不是只看已成功流水:本地已关闭、但通道确认收过钱的那一类悬账
|
* ({@code CLOSED} + 一条「取消后才收到钱」的问题记录)也必须能被定位到原支付流水,
|
* 否则连退款都发起不了——而这类钱的唯一出路就是退回付款人。
|
*
|
* @param stranded 悬账退款(只服务「订单已取消 + 通道确认收款」):走窄口子断言,跳过核销状态判断。
|
*/
|
private PreparedRefund prepare(String orderNo, OrderRefundRequest request, boolean stranded) {
|
List<Map<String, Object>> orders = rows("""
|
select o.id, o.order_no, o.talent_id, o.card_id, o.benefit_id, o.merchant_id, o.order_type,
|
o.pay_amount, o.order_status, o.verify_status,
|
p.payment_no, p.provider_transaction_no, p.created_at as payment_created_at,
|
coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id
|
from benefit_order o
|
left join payment_transaction p on p.order_id = o.id and p.provider = ?
|
and (p.status = 'SUCCEEDED'
|
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<String, Object> 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<Map<String, Object>> 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<String, Object> response) {
|
Map<String, Object> data = response.get("data") instanceof Map<?, ?> map ? (Map<String, Object>) 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(); }
|
}
|
}
|