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;
|
|
/**
|
* Second confirmation path for payments whose callback did not arrive or whose outcome was unknown.
|
*
|
* <p>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.
|
*/
|
@Service
|
public class PaymentReconciliationService {
|
private static final String PROVIDER = "HUIFU";
|
/** 订单已取消、但通道侧的预支付单还没确认关掉的流水:对账任务要一直重试关单,直到有结论。 */
|
private static final String CANCEL_UNCONFIRMED_STATUS = "CANCEL_UNCONFIRMED";
|
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<String, Object> reconcile(int pendingAfterMinutes) {
|
int paymentChecked = reconcilePayments(pendingAfterMinutes);
|
int refundChecked = reconcileRefunds(pendingAfterMinutes);
|
return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked);
|
}
|
|
/** 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.
|
*
|
* <p>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.
|
*
|
* <p>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);
|
}
|
|
/**
|
* 取消订单前的通道确认:除了确认这笔订单有没有收到钱,通道说没收款的预支付单还要在通道侧关掉。
|
*
|
* <p>只把本地流水标成已关闭是不够的:用户手上那张预支付单在通道侧还活着,照样能付进来,钱收了
|
* 而订单已经取消。所以取消必须以通道侧的关单结论为准——关单回执说原交易已经成功就拒绝取消,
|
* 说还在处理中就让用户稍后重试,只有确认关掉、或通道明确说这笔请求从未收到钱且后续也无法关单
|
* (见 {@link #closePayment})时才继续取消。
|
*/
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
public PendingPaymentConfirmation closePendingPayments(String orderNo) {
|
return decidePendingPayments(orderNo, true);
|
}
|
|
private PendingPaymentConfirmation decidePendingPayments(String orderNo, boolean closeWhenUncollected) {
|
List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
|
select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at,
|
coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id
|
from payment_transaction p
|
join benefit_order o on o.id = p.order_id
|
where p.provider = ? and p.order_no = ? and p.status not in ('SUCCEEDED', 'CLOSED')
|
order by p.id asc
|
""", PROVIDER, orderNo));
|
PendingPaymentConfirmation confirmation = new PendingPaymentConfirmation(PendingPaymentState.NONE, "");
|
for (Map<String, Object> row : rows) {
|
PendingPaymentConfirmation current = confirmPayment(row, closeWhenUncollected);
|
if (current.collected()) return current;
|
if (current.state() == PendingPaymentState.UNCONFIRMED) confirmation = current;
|
}
|
return confirmation;
|
}
|
|
/**
|
* One payment attempt, judged by what the provider says about it.
|
*
|
* <p>Only the states that cannot possibly hold money count as uncollected: a trade the provider
|
* failed, one it registered but never started, and a request number it does not know at all.
|
* Everything else — money in flight, an unreadable answer, a query that never got through —
|
* leaves the money question open rather than closed.
|
*/
|
private PendingPaymentConfirmation confirmPayment(Map<String, Object> transaction, boolean closeWhenUncollected) {
|
String paymentNo = text(transaction.get("paymentNo"));
|
String orderNo = text(transaction.get("orderNo"));
|
long amount = number(transaction.get("amount"));
|
String huifuId = text(transaction.get("huifuId"));
|
if (huifuId.isBlank()) {
|
// Without the collecting sub-merchant the provider cannot be asked about this payment.
|
issues.issue(PaymentSettlementService.ISSUE_COLLECTOR_UNKNOWN, paymentNo, orderNo, "collecting sub-merchant", "", amount,
|
"Order has no collecting Huifu sub-merchant");
|
return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
|
}
|
ProviderResult result;
|
try {
|
result = ProviderResult.from(paymentGateway.queryMiniProgramPayment(new HuifuPaymentQueryCommand(
|
paymentNo, huifuId, text(transaction.get("providerTransactionNo")), requestDate(transaction.get("createdAt")))));
|
} catch (HuifuPaymentGatewayException exception) {
|
issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "query failed", amount,
|
exception.isOutcomeUnknown() ? "Provider query outcome unknown" : "Provider query rejected");
|
return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
|
} catch (RuntimeException exception) {
|
issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "local error", amount, "Payment confirmation failed");
|
return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
|
}
|
if (result.success()) {
|
SettlementOutcome outcome = settlementService.settle(new ProviderPaymentFact(
|
paymentNo, HuifuPayload.firstNonBlank(result.huifuId(), huifuId), result.amountInCent(),
|
result.providerNo(), result.code(), result.message(), "QUERY"));
|
if (outcome.accepted()) return new PendingPaymentConfirmation(PendingPaymentState.COLLECTED, paymentNo);
|
if (outcome == SettlementOutcome.UNMATCHED || outcome == SettlementOutcome.NOT_SETTLEABLE) {
|
// Every other mismatch is already recorded with its own issue type by the settlement service.
|
issues.issue("PAYMENT_QUERY_CONFLICT", paymentNo, orderNo, "settled payment", outcome.name(), amount,
|
"Provider query succeeded but the local payment could not be settled");
|
}
|
// Money was collected but local rules refused it: it is still money, so the question stays open.
|
return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
|
}
|
if (result.notCollected()) {
|
return closeWhenUncollected
|
? closePayment(transaction)
|
: new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
|
}
|
return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
|
}
|
|
/**
|
* 通道说这笔请求没收过钱:把预支付单在通道侧关掉,取消才算真的取消。
|
*
|
* <p>关不掉的预支付单仍然可能被支付,所以「关单没成功」不能当作「可以放心取消」:
|
* <ul>
|
* <li>确认关单成功:本地记为已关闭,可以取消。</li>
|
* <li>关单回执说原交易已经成功:钱可能在,只是查单没查到——拒绝取消并记一条问题记录给人工。</li>
|
* <li>关单处理中:拒绝取消,让用户稍后重试(下次会重新确认)。</li>
|
* <li>其余拒绝(未满一分钟不允许关单、原交易不支持关单、关单请求本身失败):允许取消,但流水记为
|
* {@code CANCEL_UNCONFIRMED} 而不是已关闭——对账任务仍会盯着这笔请求,万一用户后来真的付了,
|
* 会留下问题记录让人处理,而不是无声地变成一笔没有订单的悬账。</li>
|
* </ul>
|
*/
|
private PendingPaymentConfirmation closePayment(Map<String, Object> transaction) {
|
String paymentNo = text(transaction.get("paymentNo"));
|
String orderNo = text(transaction.get("orderNo"));
|
long amount = number(transaction.get("amount"));
|
String huifuId = text(transaction.get("huifuId"));
|
Map<String, Object> response;
|
try {
|
response = paymentGateway.closeMiniProgramPayment(new HuifuPaymentCloseCommand(
|
BusinessIdGenerator.next("C"), paymentNo, huifuId, text(transaction.get("providerTransactionNo")),
|
requestDate(transaction.get("createdAt"))));
|
} catch (RuntimeException exception) {
|
return keepWatchableAfterRefusedClose(transaction, "CLOSE_FAILED", "Provider close request failed", amount);
|
}
|
CloseResult close = CloseResult.from(response);
|
if (close.closed()) {
|
markCancelledTransaction(paymentNo, "CLOSED", close.code(), close.message());
|
issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system");
|
return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
|
}
|
if (close.originalTradeSucceeded()) {
|
issues.issue("PAYMENT_CLOSE_CONFLICT", paymentNo, orderNo, "closed at provider",
|
"original trade already succeeded", amount,
|
"Provider refused to close the payment because the original trade already succeeded");
|
return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
|
}
|
if (close.processing()) {
|
issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, orderNo, "closed at provider", "close processing", amount,
|
"Provider is still closing the payment");
|
return new PendingPaymentConfirmation(PendingPaymentState.UNCONFIRMED, paymentNo);
|
}
|
return keepWatchableAfterRefusedClose(transaction, close.code(), close.message(), amount);
|
}
|
|
private PendingPaymentConfirmation keepWatchableAfterRefusedClose(Map<String, Object> transaction, String code,
|
String message, long amount) {
|
String paymentNo = text(transaction.get("paymentNo"));
|
markCancelledTransaction(paymentNo, "CANCEL_UNCONFIRMED", code, message);
|
issues.issue("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, text(transaction.get("orderNo")), "closed at provider",
|
code, amount, "Payment could not be closed at the provider; the prepay may still be payable");
|
return new PendingPaymentConfirmation(PendingPaymentState.NONE, paymentNo);
|
}
|
|
private void markCancelledTransaction(String paymentNo, String status, String code, String message) {
|
jdbc.update("""
|
update payment_transaction
|
set status = ?, closed_at = coalesce(closed_at, now()),
|
provider_response_code = ?, provider_response_message = ?
|
where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CREATE_FAILED', 'CANCEL_UNCONFIRMED')
|
""", status, code, message, paymentNo);
|
}
|
|
/** 通道对关单请求的答复。只有「关掉了」和「原交易已成功」是明确结论,其余一律当作没确认下来。 */
|
private record CloseResult(String code, String message, String status, String originalStatus) {
|
private static CloseResult from(Map<String, Object> response) {
|
Map<String, Object> data = response == null ? Map.of()
|
: (response.get("data") instanceof Map<?, ?> map ? (Map<String, Object>) map : response);
|
return new CloseResult(HuifuPayload.code(data), HuifuPayload.message(data),
|
HuifuPayload.value(data, "trans_stat", "transStat"),
|
HuifuPayload.value(data, "org_trans_stat", "orgTransStat"));
|
}
|
|
boolean closed() {
|
return "S".equalsIgnoreCase(status) && HuifuPayload.successCode(code);
|
}
|
|
boolean originalTradeSucceeded() {
|
return "S".equalsIgnoreCase(originalStatus);
|
}
|
|
boolean processing() {
|
return "P".equalsIgnoreCase(status) || "00000100".equals(code);
|
}
|
}
|
|
private int reconcilePayments(int pendingAfterMinutes) {
|
List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
|
select p.payment_no, p.order_no, p.amount, p.status, p.provider_transaction_no, p.created_at,
|
coalesce(p.provider_huifu_id, o.collect_huifu_id) as huifu_id
|
from payment_transaction p
|
join benefit_order o on o.id = p.order_id
|
where p.provider = ? and p.status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED')
|
and p.created_at <= date_sub(now(), interval ? minute)
|
order by p.id asc limit 100
|
""", PROVIDER, Math.max(1, pendingAfterMinutes)));
|
for (Map<String, Object> row : rows) {
|
if (CANCEL_UNCONFIRMED_STATUS.equals(text(row.get("status")))) retryClose(row);
|
else reconcilePayment(row, pendingAfterMinutes);
|
}
|
return rows.size();
|
}
|
|
/**
|
* 关单补偿:取消订单时通道不让关(未满一分钟、通道临时拒绝、关单请求失败)而留下的
|
* {@code CANCEL_UNCONFIRMED},等过了限制再关一次——不关掉,用户手上那张预支付单一直能付进来。
|
*
|
* <p>先确认、再关单:通道说这笔请求没收过钱就关掉,落成已关闭;通道说钱已经收下了则绝不再尝试关单,
|
* 交给结算与问题记录把它变成一笔看得见、可退的账。两种情况都说明这一轮有了结论,把上一轮留下的
|
* 关单问题记录结掉。
|
*/
|
private void retryClose(Map<String, Object> transaction) {
|
String paymentNo = text(transaction.get("paymentNo"));
|
issues.resolve("PAYMENT_CLOSE_UNCONFIRMED", paymentNo, "system");
|
confirmPayment(transaction, true);
|
}
|
|
private void reconcilePayment(Map<String, Object> transaction, int pendingAfterMinutes) {
|
String paymentNo = text(transaction.get("paymentNo"));
|
String orderNo = text(transaction.get("orderNo"));
|
long amount = number(transaction.get("amount"));
|
String huifuId = text(transaction.get("huifuId"));
|
if (huifuId.isBlank()) {
|
// Without the collecting sub-merchant the provider cannot be asked about this payment.
|
issues.issue(PaymentSettlementService.ISSUE_COLLECTOR_UNKNOWN, paymentNo, orderNo, "collecting sub-merchant", "", amount,
|
"Order has no collecting Huifu sub-merchant");
|
return;
|
}
|
try {
|
Map<String, Object> response = paymentGateway.queryMiniProgramPayment(new HuifuPaymentQueryCommand(
|
paymentNo, huifuId, text(transaction.get("providerTransactionNo")), requestDate(transaction.get("createdAt"))));
|
ProviderResult result = ProviderResult.from(response);
|
if (result.success()) {
|
// The query was made with the order's sub-merchant, so that number is the expected one.
|
SettlementOutcome outcome = settlementService.settle(new ProviderPaymentFact(
|
paymentNo, HuifuPayload.firstNonBlank(result.huifuId(), huifuId), result.amountInCent(),
|
result.providerNo(), result.code(), result.message(), "QUERY"));
|
if (outcome.accepted()) {
|
issues.resolve("PAYMENT_PENDING_TIMEOUT", paymentNo, "system");
|
} else if (outcome == SettlementOutcome.UNMATCHED || outcome == SettlementOutcome.NOT_SETTLEABLE) {
|
// Every other mismatch is already recorded with its own issue type by the settlement service.
|
issues.issue("PAYMENT_QUERY_CONFLICT", paymentNo, orderNo, "settled payment", outcome.name(), amount,
|
"Provider query succeeded but the local payment could not be settled");
|
}
|
} else if (result.failed()) {
|
markCreateFailed(paymentNo, result.code(), result.message());
|
issues.issue("PAYMENT_PROVIDER_FAILED", paymentNo, orderNo, "payment pending", result.status(), amount, result.message());
|
} else {
|
issues.issue("PAYMENT_PENDING_TIMEOUT", paymentNo, orderNo, "payment completed", result.status(), amount,
|
"Payment remains pending after " + pendingAfterMinutes + " minutes");
|
}
|
} catch (HuifuPaymentGatewayException exception) {
|
issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "query failed", amount,
|
exception.isOutcomeUnknown() ? "Provider query outcome unknown" : "Provider query rejected");
|
} catch (RuntimeException exception) {
|
issues.issue("PAYMENT_QUERY_FAILED", paymentNo, orderNo, "payment status", "local error", amount, "Payment reconciliation failed");
|
}
|
}
|
|
private void markCreateFailed(String paymentNo, String code, String message) {
|
jdbc.update("""
|
update payment_transaction
|
set status = 'CREATE_FAILED', provider_response_code = ?, provider_response_message = ?
|
where payment_no = ? and status in ('CREATED', 'PREPAY_CREATED', 'CREATE_UNKNOWN')
|
""", code, message, paymentNo);
|
}
|
|
private int reconcileRefunds(int pendingAfterMinutes) {
|
List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
|
select refund_no, order_no, refund_amount, created_at from order_refund
|
where deleted = 0 and provider = ? and refund_status = 1
|
and created_at <= date_sub(now(), interval ? minute)
|
order by id asc limit 100
|
""", PROVIDER, Math.max(1, pendingAfterMinutes)));
|
for (Map<String, Object> row : rows) {
|
String refundNo = text(row.get("refundNo"));
|
try {
|
Map<String, Object> result = refundService.query(refundNo);
|
if (number(result.get("refundStatus")) == 1) issues.issue("REFUND_PENDING_TIMEOUT", refundNo, text(row.get("orderNo")), "refund completed", "refund pending", number(row.get("refundAmount")), "Refund remains pending after " + pendingAfterMinutes + " minutes");
|
else issues.resolve("REFUND_PENDING_TIMEOUT", refundNo, "system");
|
} catch (RuntimeException exception) {
|
issues.issue("REFUND_QUERY_FAILED", refundNo, text(row.get("orderNo")), "refund status", "query failed", number(row.get("refundAmount")), "Refund reconciliation failed");
|
}
|
}
|
return rows.size();
|
}
|
|
private String requestDate(Object createdAt) { return createdAt instanceof LocalDateTime time ? REQUEST_DATE.format(time) : REQUEST_DATE.format(LocalDateTime.now()); }
|
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(); }
|
|
@SuppressWarnings("unchecked")
|
private record ProviderResult(boolean success, boolean failed, String status, String code, String message, String providerNo,
|
String huifuId, Long amountInCent) {
|
/** 汇付「交易不存在」:通道不认识这个请求流水号,也就不可能为它收过钱。 */
|
private static final String TRADE_MISSING = "23000001";
|
|
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 = HuifuPayload.value(data, "trans_stat", "transStat");
|
String code = HuifuPayload.code(data);
|
String message = HuifuPayload.message(data);
|
// 支付查询回执里通道全局流水号的字段是 org_hf_seq_id;hf_seq_id 只出现在异步通知里。
|
// 这里保留 hf_seq_id 兜底,避免通道换字段名后又被静默丢成 NULL。
|
String providerNo = HuifuPayload.value(data, "org_hf_seq_id", "orgHfSeqId", "hf_seq_id", "hfSeqId");
|
String huifuId = HuifuPayload.value(data, "huifu_id", "huifuId");
|
TransStatus classification = HuifuPayload.status(status);
|
boolean success = classification == TransStatus.SUCCESS && HuifuPayload.successCode(code);
|
// An unknown or missing status means "cannot confirm yet", not "failed".
|
boolean pending = classification == TransStatus.PENDING || classification == TransStatus.UNKNOWN;
|
return new ProviderResult(success, !success && !pending, status, code, message, providerNo, huifuId,
|
HuifuPayload.amountInCent(HuifuPayload.value(data, "trans_amt", "transAmt")));
|
}
|
|
/**
|
* 通道是否明确排除了「已经收到钱」。
|
*
|
* <p>失败的交易、只登记未开始的交易({@code I} 初始)和通道根本不认识的请求流水号,都不可能
|
* 已经把钱收下;其余回执(处理中、读不出来的状态)都可能,因此不能算作已排除。
|
*/
|
boolean notCollected() {
|
return failed || "I".equalsIgnoreCase(status) || TRADE_MISSING.equals(code);
|
}
|
}
|
}
|