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.huifu.HuifuPayload; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** * The single idempotent entry point that turns a provider-confirmed successful payment into local * state. * *

A callback and a reconciliation query must produce the same business result, so both describe * what the provider said as a {@link ProviderPaymentFact} and let this service decide. The fact can * only settle the payment transaction its request number names, and before anything changes it is * compared with the order's fixed collecting sub-merchant and the transaction amount. A statement * that cannot be verified — another merchant, a missing merchant number, a missing or different * amount — is recorded as an open reconciliation issue and the order is never marked paid. Repeated * facts for the same payment converge on the same state: the payment transaction is settled once, and * the order is only moved from pending to paid. */ @Service public class PaymentSettlementService { static final String PROVIDER = "HUIFU"; static final String ISSUE_MERCHANT_MISMATCH = "PAYMENT_MERCHANT_MISMATCH"; static final String ISSUE_MERCHANT_UNVERIFIED = "PAYMENT_MERCHANT_UNVERIFIED"; static final String ISSUE_AMOUNT_MISMATCH = "PAYMENT_AMOUNT_MISMATCH"; static final String ISSUE_COLLECTOR_UNKNOWN = "PAYMENT_COLLECTOR_UNKNOWN"; static final String ISSUE_ORDER_CANCELLED = "PAYMENT_ORDER_CANCELLED"; private final JdbcTemplate jdbc; private final PaymentIssueRecorder issues; private final com.talentcard.server.business.order.OrderVerificationService orderVerification; public PaymentSettlementService(JdbcTemplate jdbc, PaymentIssueRecorder issues, @org.springframework.context.annotation.Lazy com.talentcard.server.business.order.OrderVerificationService orderVerification) { this.jdbc = jdbc; this.issues = issues; this.orderVerification = orderVerification; } @Transactional public SettlementOutcome settle(ProviderPaymentFact fact) { List> rows = DatabaseRows.normalize(jdbc.queryForList(""" select p.id, p.order_id, p.order_no, p.status, p.amount, p.provider_huifu_id, o.order_status, o.collect_huifu_id from payment_transaction p join benefit_order o on o.id = p.order_id where p.provider = ? and p.payment_no = ? limit 1 for update """, PROVIDER, fact.paymentNo())); if (rows.isEmpty()) return SettlementOutcome.UNMATCHED; Map payment = rows.get(0); long paymentId = number(payment.get("id")); String orderNo = text(payment.get("orderNo")); long amountInCent = number(payment.get("amount")); boolean alreadySucceeded = "SUCCEEDED".equals(text(payment.get("status"))); boolean closed = "CLOSED".equals(text(payment.get("status"))); if (!fact.amountMatches(amountInCent)) { issues.issue(ISSUE_AMOUNT_MISMATCH, fact.paymentNo(), orderNo, String.valueOf(amountInCent), fact.amountText(), amountInCent, "Provider amount is missing or does not match the local payment amount"); return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.AMOUNT_MISMATCH; } String expectedHuifuId = HuifuPayload.firstNonBlank(text(payment.get("providerHuifuId")), text(payment.get("collectHuifuId"))); if (expectedHuifuId.isBlank()) { issues.issue(ISSUE_COLLECTOR_UNKNOWN, fact.paymentNo(), orderNo, "collecting sub-merchant", fact.huifuId(), amountInCent, "Order has no collecting Huifu sub-merchant"); return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.COLLECTOR_UNKNOWN; } if (fact.huifuId().isBlank()) { // A success statement without a merchant number cannot be attributed to this sub-merchant. issues.issue(ISSUE_MERCHANT_UNVERIFIED, fact.paymentNo(), orderNo, expectedHuifuId, "", amountInCent, "Provider merchant number is missing"); return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.MERCHANT_UNVERIFIED; } if (!expectedHuifuId.equals(fact.huifuId())) { issues.issue(ISSUE_MERCHANT_MISMATCH, fact.paymentNo(), orderNo, expectedHuifuId, fact.huifuId(), amountInCent, "Provider merchant number does not match the order's collecting sub-merchant"); return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.MERCHANT_MISMATCH; } if (closed) { // The talent user cancelled while the provider still completed the payment. The money // exists but the order must stay cancelled until the refund path settles it. issues.issue(ISSUE_ORDER_CANCELLED, fact.paymentNo(), orderNo, "cancelled", "provider confirmed " + fact.source(), amountInCent, "Payment succeeded after the local order was cancelled"); return SettlementOutcome.CLOSED_LATE_SUCCESS; } if (!alreadySucceeded) { // CANCEL_UNCONFIRMED 也在这里:订单取消后用户又把预支付单付了,钱必须落成已支付的事实, // 再让下面的订单状态判断把它记成「取消后才收到钱」的问题记录。 int paymentUpdated = jdbc.update(""" update payment_transaction set status = 'SUCCEEDED', provider_transaction_no = coalesce(nullif(?, ''), provider_transaction_no), provider_response_code = ?, provider_response_message = ?, paid_at = coalesce(paid_at, now()) where id = ? and status in ('CREATED', 'CREATE_FAILED', 'PREPAY_CREATED', 'CREATE_UNKNOWN', 'CANCEL_UNCONFIRMED') """, fact.providerTransactionNo(), fact.responseCode(), fact.responseMessage(), paymentId); if (paymentUpdated == 0) return SettlementOutcome.NOT_SETTLEABLE; } long orderStatus = number(payment.get("orderStatus")); if (orderStatus == 1) return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.SETTLED; if (orderStatus != 0) { issues.issue(ISSUE_ORDER_CANCELLED, fact.paymentNo(), orderNo, "cancelled", "provider confirmed " + fact.source(), amountInCent, "Payment succeeded after the local order was cancelled"); return SettlementOutcome.CLOSED_LATE_SUCCESS; } // STORE_CONSUME orders are fulfilled at payment time, so the query path must verify them // exactly like the callback path does. int orderUpdated = jdbc.update(""" update benefit_order set order_status = 1, verify_status = case when order_type = 'STORE_CONSUME' then 1 else verify_status end where id = ? and order_status = 0 """, payment.get("orderId")); if (orderUpdated == 1) { // 到店消费的核销是支付的副产品。补一条核销记录,否则这笔核销在数据里没有任何凭证, // 冲正、纠纷举证与按核销统计都会缺一块。方法内部幂等,重复回调不会产生第二条记录。 orderVerification.recordAutomaticVerification(number(payment.get("orderId")), java.time.LocalDateTime.now()); } return alreadySucceeded ? SettlementOutcome.ALREADY_SETTLED : SettlementOutcome.SETTLED; } 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(); } /** What the provider said about one payment, normalised so callbacks and queries look the same. */ public record ProviderPaymentFact( String paymentNo, String huifuId, Long amountInCent, String providerTransactionNo, String responseCode, String responseMessage, String source ) { public ProviderPaymentFact { paymentNo = paymentNo == null ? "" : paymentNo.trim(); huifuId = huifuId == null ? "" : huifuId.trim(); providerTransactionNo = providerTransactionNo == null ? "" : providerTransactionNo.trim(); responseCode = responseCode == null ? "" : responseCode.trim(); responseMessage = responseMessage == null ? "" : responseMessage.trim(); source = source == null ? "" : source.trim(); } boolean amountMatches(long localAmountInCent) { return amountInCent != null && amountInCent == localAmountInCent; } String amountText() { return amountInCent == null ? "" : String.valueOf(amountInCent); } } public enum SettlementOutcome { /** The provider fact was accepted and the order is now paid. */ SETTLED, /** The payment was already settled; nothing changed. */ ALREADY_SETTLED, /** No local payment transaction matches the provider request number. */ UNMATCHED, /** The provider merchant number is not the order's collecting sub-merchant. */ MERCHANT_MISMATCH, /** The success statement carries no merchant number, so it cannot be attributed. */ MERCHANT_UNVERIFIED, /** The provider amount is missing or different from the local payment amount. */ AMOUNT_MISMATCH, /** The order carries no collecting sub-merchant, so the fact cannot be trusted. */ COLLECTOR_UNKNOWN, /** The payment succeeded after the local order was cancelled or closed. */ CLOSED_LATE_SUCCESS, /** The payment transaction is in a state that cannot be settled. */ NOT_SETTLEABLE; /** Whether local state now reflects a provider-confirmed successful payment. */ public boolean accepted() { return this == SETTLED || this == ALREADY_SETTLED; } } }