package com.talentcard.server.business.payment;
|
|
import java.util.List;
|
import java.util.Map;
|
|
import com.talentcard.server.business.DatabaseRows;
|
import com.talentcard.server.business.payment.HuifuRefundService.StrandedRefundOutcome;
|
import org.slf4j.Logger;
|
import org.slf4j.LoggerFactory;
|
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.stereotype.Component;
|
|
/**
|
* 悬账的自动出口:通道确认收到、但订单已经取消的钱,自动退回付款人。
|
*
|
* <p>这类钱是「回调没到达 + 用户后来又把预支付单付了」的产物:本地订单已取消,通道却把钱收下了。
|
* 它只有一条正确出路——原路退回付款人,没有需要人判断的地方;留给人工只会让它一直挂在页面上。
|
*
|
* <p>消费的是 {@code PAYMENT_ORDER_CANCELLED} 问题记录(由结算服务在「取消后才确认收款」时写下):
|
* 调窄口子退款入口,受理成功就把问题记录结掉;**通道明确拒绝**(例如商户基本户余额不足)则升级告警
|
* 转人工,问题记录保持未结——绝不静默吞掉。
|
*
|
* <p>这不是新增「让状态能推进」的任务:钱的真相本来就由对账的查单带回来,这里只是把它落到退款上。
|
*/
|
@Component
|
public class StrandedPaymentRefundRetry {
|
|
private static final Logger log = LoggerFactory.getLogger(StrandedPaymentRefundRetry.class);
|
/** 结算服务为「取消后才确认收款」写下的问题类型。 */
|
static final String ISSUE_ORDER_CANCELLED = "PAYMENT_ORDER_CANCELLED";
|
/** 自动退款被通道拒绝时写下的问题类型:与上面那条分开,管理端一眼能看出「这笔要人工」。 */
|
static final String ISSUE_REFUND_REJECTED = "STRANDED_REFUND_REJECTED";
|
/** 每轮最多处理几笔,避免一次拉太多拖长任务。 */
|
private static final int BATCH_SIZE = 20;
|
|
private final JdbcTemplate jdbc;
|
private final HuifuRefundService refundService;
|
private final PaymentIssueRecorder issues;
|
|
@Value("${app.payment.stranded-refund.enabled:true}")
|
private boolean enabled = true;
|
|
public StrandedPaymentRefundRetry(JdbcTemplate jdbc, HuifuRefundService refundService, PaymentIssueRecorder issues) {
|
this.jdbc = jdbc;
|
this.refundService = refundService;
|
this.issues = issues;
|
}
|
|
/**
|
* 定期把悬账退回付款人。默认 5 分钟一轮;用 {@code app.payment.stranded-refund.enabled} 关闭,
|
* 用 {@code ...fixed-delay-ms} 调整频率。
|
*/
|
@Scheduled(fixedDelayString = "${app.payment.stranded-refund.fixed-delay-ms:300000}")
|
public void refundStrandedPayments() {
|
if (!enabled) return;
|
try {
|
int refunded = retry(BATCH_SIZE);
|
if (refunded > 0) log.info("Started {} stranded refund(s) for cancelled orders", refunded);
|
} catch (RuntimeException exception) {
|
log.warn("Stranded refund run failed: {}", exception.getClass().getSimpleName());
|
}
|
}
|
|
/** 返回本轮真正发起过退款的笔数(供测试与运维使用)。 */
|
public int retry(int limit) {
|
List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
|
select business_no, order_no, amount from payment_reconciliation_issue
|
where issue_type = ? and provider = 'HUIFU' and status = 'open'
|
and order_no is not null and order_no <> ''
|
order by detected_at asc, id asc
|
limit ?
|
""", ISSUE_ORDER_CANCELLED, Math.max(1, limit)));
|
int refunded = 0;
|
for (Map<String, Object> row : rows) {
|
String paymentNo = text(row.get("businessNo"));
|
String orderNo = text(row.get("orderNo"));
|
long amount = number(row.get("amount"));
|
StrandedRefundOutcome outcome = refundService.refundStranded(orderNo);
|
switch (outcome) {
|
case SUBMITTED -> {
|
// 退款已受理:这类钱有了唯一的出口,问题记录可以结了。退款自己的最终结果由退款
|
// 对账与读路径收敛(通道对退款也是先回「处理中」)。
|
issues.resolve(ISSUE_ORDER_CANCELLED, paymentNo, "system");
|
issues.resolve(ISSUE_REFUND_REJECTED, paymentNo, "system");
|
refunded++;
|
log.info("Stranded payment {} of cancelled order {} is being refunded to the payer", paymentNo, orderNo);
|
}
|
case REJECTED -> {
|
// 通道明确拒绝:钱退不回去,必须人工介入。问题记录保持未结,另记一条告警类型,
|
// 让管理端一眼看到「这笔悬账退不动」。
|
issues.issue(ISSUE_REFUND_REJECTED, paymentNo, orderNo, "refunded", "refund rejected", amount,
|
"The provider rejected the automatic refund of a cancelled order's payment; a human has to refund it");
|
log.error("Stranded payment {} of cancelled order {} could not be refunded automatically;"
|
+ " the provider rejected the refund and it needs a human", paymentNo, orderNo);
|
}
|
default -> log.warn("Stranded payment {} of cancelled order {} has no refundable payment yet;"
|
+ " the issue stays open for a human", paymentNo, orderNo);
|
}
|
}
|
return refunded;
|
}
|
|
private long number(Object value) { return value instanceof Number number ? number.longValue() : 0; }
|
private String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
|
}
|