package com.talentcard.server.business.payment; import com.talentcard.server.business.DatabaseRows; 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.HuifuPaymentGatewayException; import com.talentcard.server.business.payment.huifu.HuifuPaymentQueryCommand; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; 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. * *

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 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); return Map.of("paymentChecked", paymentChecked, "refundChecked", refundChecked); } private int reconcilePayments(int pendingAfterMinutes) { List> 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') and p.created_at <= date_sub(now(), interval ? minute) order by p.id asc limit 100 """, PROVIDER, Math.max(1, pendingAfterMinutes))); for (Map row : rows) reconcilePayment(row, pendingAfterMinutes); return rows.size(); } private void reconcilePayment(Map 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 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> 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 row : rows) { String refundNo = text(row.get("refundNo")); try { Map 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 ProviderResult from(Map response) { Map data = response.get("data") instanceof Map map ? (Map) 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"))); } } }