package com.talentcard.server.business.payment;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.payment.huifu.HuifuPayload;
import com.talentcard.server.business.payment.huifu.HuifuPayload.TransStatus;
import com.talentcard.server.business.payment.PaymentSettlementService.ProviderPaymentFact;
import com.talentcard.server.business.payment.PaymentSettlementService.SettlementOutcome;
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPaymentQueryCommand;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Second confirmation path for payments whose callback did not arrive or whose outcome was unknown.
*
*
A query result is validated exactly like a callback: it is described as a
* {@link ProviderPaymentFact} and settled by {@link PaymentSettlementService}, so both paths produce
* the same local result. A query that cannot confirm the payment leaves the transaction pending and
* records an open issue instead of guessing.
*
*
取消与放弃以通道的**关单回执**为准,而不是把查单当成关单的前置门:关单回执同时回答「关掉了没有」
* 和「原交易是不是已经成功」,比查单更强。查单只会说「处理中」,旧实现因此连关单都不试,用户手上那张
* 预支付单一直能付进来,订单也永远既付不了又退不出(判定见 {@code docs/adr/0003})。
*/
@Service
public class PaymentReconciliationService {
private static final String PROVIDER = "HUIFU";
/** 订单已取消、但通道侧的预支付单还没确认关掉的流水:对账任务要一直重试关单,直到有结论。 */
private static final String CANCEL_UNCONFIRMED_STATUS = "CANCEL_UNCONFIRMED";
/** 已发出关单请求、通道只回「处理中」的支付尝试:可等待、可重试的中间态,不是终点。 */
private static final String CLOSING_STATUS = "CLOSING";
/** 关单重试上限:到上限停止重试,把这次尝试落成「取消未确认」并结束订单。 */
private static final int MAX_CLOSE_ATTEMPTS = 3;
/** 关单快车道间隔(分钟)。
*
*
通道不允许关闭一分钟以内的订单,所以它同时也是最短的重试间隔:比这更密只会拿到同样的拒绝。
*/
private static final int CLOSE_RETRY_MINUTES = 1;
private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
private final JdbcTemplate jdbc;
private final HuifuMiniProgramPaymentGateway paymentGateway;
private final HuifuRefundService refundService;
private final PaymentSettlementService settlementService;
private final PaymentIssueRecorder issues;
public PaymentReconciliationService(JdbcTemplate jdbc, HuifuMiniProgramPaymentGateway paymentGateway, HuifuRefundService refundService,
PaymentSettlementService settlementService, PaymentIssueRecorder issues) {
this.jdbc = jdbc;
this.paymentGateway = paymentGateway;
this.refundService = refundService;
this.settlementService = settlementService;
this.issues = issues;
}
public Map reconcile(int pendingAfterMinutes) {
int paymentChecked = reconcilePayments(pendingAfterMinutes);
int refundChecked = reconcileRefunds(pendingAfterMinutes);
// 关单结论回来之后,替已经点过取消的用户把订单收掉。放在查单/关单之后:同一轮里先把这次尝试
// 问出结论,再决定这笔订单能不能结束。
int cancelledOnRequest = cancelOrdersAwaitingCloseConclusion();
return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked,
"cancelledOnRequest", cancelledOnRequest);
}
/**
* 落地「用户点过取消、但当时关单还没有结论」的订单。
*
* 取消在通道说「关单处理中」时只能先受理(见 {@code PendingOrderPortalService.cancel}),订单要等
* 每笔尝试都有了结论才能结束。那之后必须有人替用户把这一步走完:关单关掉了就取消,通道说钱收下了就
* 交给结算把它翻成已支付——否则用户点的取消会随着页面关掉而丢失,订单一直挂在「待支付」,他只能再点
* 一次取消,或者等到 15 分钟超时被清理收掉。
*
*
条件与「取消」本身一致:不留未定局的尝试(它们还可能被支付),也不取消已经收到钱的订单。
*/
private int cancelOrdersAwaitingCloseConclusion() {
return jdbc.update("""
update benefit_order o set o.order_status = 2
where o.order_status = 0 and o.deleted = 0 and o.cancel_requested_at is not null
and not exists (
select 1 from payment_transaction p
where p.order_id = o.id
and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CLOSING', 'CANCEL_UNCONFIRMED')
)
and not exists (
select 1 from payment_transaction p
where p.order_id = o.id and p.status = 'SUCCEEDED'
)
""");
}
/** What the provider said about the payments of one order whose local state is still undecided. */
public enum PendingPaymentState {
/** The provider confirmed money was collected: local state now has the order paid. */
COLLECTED,
/** The provider reports no collected payment, so local state may move on. */
NONE,
/** The provider could not say whether money was collected, so the question stays open. */
UNCONFIRMED
}
public record PendingPaymentConfirmation(PendingPaymentState state, String paymentNo) {
public boolean collected() { return state == PendingPaymentState.COLLECTED; }
}
/**
* Asks the provider about every payment of one order that local state has not decided yet.
*
*
Throwing an order away — cancelling it, or asking the user to pay again — needs one answer
* first: did the provider already collect money? Local state cannot answer that when the
* notification never arrived and only the provider knows, so the provider is asked instead. A
* confirmed payment settles the order; a provider that cannot answer stays unconfirmed, because
* a question about money is never answered by guessing.
*
*
Runs in its own transaction: the caller may roll back (a refused cancellation throws), and
* the provider's answer is a fact that has to survive that rollback.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public PendingPaymentConfirmation confirmPendingPayments(String orderNo) {
return decidePendingPayments(orderNo, false, null);
}
/**
* 取消订单前的通道确认:除了确认这笔订单有没有收到钱,通道说没收款的预支付单还要在通道侧关掉。
*
*
只把本地流水标成已关闭是不够的:用户手上那张预支付单在通道侧还活着,照样能付进来,钱收了
* 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就落成已支付并拒绝
* 取消,说还在处理中就让这次尝试停在 {@code CLOSING} 并交给后台重试,只有确认关掉、或通道明确拒绝
* 关单(见 {@link #keepWatchableAfterRefusedClose})时才继续取消。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public PendingPaymentConfirmation closePendingPayments(String orderNo) {
return decidePendingPayments(orderNo, true, null);
}
/**
* 「放弃支付」:客户端在收银台里点了关闭,请求服务端立刻把这笔尝试关掉。
*
*
只触发关单,绝不结算。客户端关于钱的陈述不可信——它说「我放弃了」只能当作一个关单信号,
* 收款与否一律以关单回执为准。因此这里刻意不调用 {@link #confirmPayment} 的确认分支:即使回执说原
* 交易已经成功,也只记一条问题记录,让后续的查单与对账把它落成真实的资金事实。
*
* @param paymentNo 客户端手上那张预支付单;为空时处理该订单所有未定局的尝试。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public PendingPaymentConfirmation abandonPendingPayments(String orderNo, String paymentNo) {
PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, "");
for (Map row : undecidedPayments(orderNo, paymentNo)) {
PendingPaymentConfirmation current = closePayment(row, false);
if (current.state() == PendingPaymentState.UNCONFIRMED) confirmation = current;
}
return confirmation;
}
private PendingPaymentConfirmation decidePendingPayments(String orderNo, boolean closeWhenUncollected, String paymentNo) {
PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, "");
for (Map row : undecidedPayments(orderNo, paymentNo)) {
PendingPaymentConfirmation current = confirmPayment(row, closeWhenUncollected);
if (current.collected()) return current;
if (current.state() == PendingPaymentState.UNCONFIRMED) confirmation = current;
}
return confirmation;
}
/** 一笔订单里本地还没定局的支付尝试(含 CLOSING 与 CANCEL_UNCONFIRMED,它们的预支付单仍可能被支付)。 */
private List