package com.ruoyi.app.omgpay; import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse; import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt; import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot; import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.beans.factory.annotation.Autowired; import java.math.BigDecimal; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.*; /** Queries the one current payment attempt and compensates for a lost paid callback. */ @Service public class OmgPaymentQueryService { private static final Logger log = LoggerFactory.getLogger(OmgPaymentQueryService.class); private static final int ATTEMPT_PAID = 1; private static final String TRADE_UNPAID = "0"; private static final String TRADE_PAID = "1"; private static final String TRADE_FAILED = "10200095"; private static final DateTimeFormatter OMG_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"); private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei"); private static final List REQUIRED_FIELDS = List.of( "MerchantID", "MerchantTradeNo", "StoreID", "TradeNo", "TradeAmt", "PaymentDate", "PaymentType", "HandlingCharge", "PaymentTypeChargeFee", "TradeDate", "TradeStatus", "ItemName", "CustomField1", "CustomField2", "CustomField3", "CustomField4", "CheckMacValue"); private final IOmgPaymentAttemptService attempts; private final OmgPaymentQueryGateway gateway; private final OmgQueryResponseParser parser; private final OmgCheckMacSigner signer; private final OmgPaymentNotifyService settlement; private final Clock clock; @Autowired public OmgPaymentQueryService(IOmgPaymentAttemptService attempts, OmgPaymentQueryGateway gateway, OmgQueryResponseParser parser, OmgCheckMacSigner signer, OmgPaymentNotifyService settlement) { this(attempts, gateway, parser, signer, settlement, Clock.systemUTC()); } OmgPaymentQueryService(IOmgPaymentAttemptService attempts, OmgPaymentQueryGateway gateway, OmgQueryResponseParser parser, OmgCheckMacSigner signer, OmgPaymentNotifyService settlement, Clock clock) { this.attempts = attempts; this.gateway = gateway; this.parser = parser; this.signer = signer; this.settlement = settlement; this.clock = clock; } public OmgQueryPaymentResponse query(Long userId, String orderId) { if (userId == null) { throw business(AUTH_REQUIRED); } String normalizedOrderId = normalizeOrderId(orderId); OmgPaymentOrderSnapshot order = attempts.selectOrder(normalizedOrderId); if (order == null || !userId.equals(order.getUserId())) { throw business(ORDER_NOT_AVAILABLE); } OmgPaymentAttempt attempt = selectCurrentAttempt(order); validateAttempt(attempt, order.getStoreId()); log.info("OMG payment query started orderId={}, userId={}, storeId={}, merchantTradeNo={}", OmgPaymentController.safeLogOrderId(order.getDdId()), userId, order.getStoreId(), OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo())); return queryAttempt(attempt, order.getDdId(), order.getStoreId(), "USER"); } /** Reuses the verified query and settlement path for one scheduler-reserved CREATED attempt. */ public OmgQueryPaymentResponse reconcile(OmgPaymentAttempt attempt) { Long storeId = attempt == null ? null : attempt.getStoreId(); validateAttempt(attempt, storeId); log.info("OMG automatic compensation query started orderId={}, storeId={}, merchantTradeNo={}, queryCount={}", OmgPaymentController.safeLogOrderId(attempt.getDdId()), storeId, OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), attempt.getQueryCount()); return queryAttempt(attempt, attempt.getDdId(), storeId, "AUTO"); } private OmgQueryPaymentResponse queryAttempt(OmgPaymentAttempt attempt, String orderId, Long storeId, String source) { try { Map response = parser.parse(gateway.query(buildRequest(attempt))); validateResponse(response, attempt); String tradeStatus = response.get("TradeStatus"); String status = synchronizeIfFinal(tradeStatus, response, attempt); log.info("OMG payment query completed source={}, orderId={}, merchantTradeNo={}, tradeNo={}, " + "gatewayTradeStatus={}, localStatus={}", source, OmgPaymentController.safeLogOrderId(orderId), OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), maskTradeNo(response.get("TradeNo")), tradeStatus, status); return toResponse(status, response); } catch (OmgPaymentBusinessException exception) { throw exception; } catch (Exception exception) { log.error("OMG payment query failed source={}, orderId={}, merchantTradeNo={}", source, OmgPaymentController.safeLogOrderId(orderId), OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), exception); throw business(PAYMENT_QUERY_FAILED, storeId); } } private OmgPaymentAttempt selectCurrentAttempt(OmgPaymentOrderSnapshot order) { if (order.getPayStatus() != null && order.getPayStatus() == 1L) { return attempts.selectPaidByDdId(order.getDdId()); } return attempts.selectActiveCreatedByDdId(order.getDdId()); } private Map buildRequest(OmgPaymentAttempt attempt) { LinkedHashMap fields = new LinkedHashMap<>(); fields.put("MerchantID", attempt.getMerchantId()); fields.put("MerchantTradeNo", attempt.getMerchantTradeNo()); fields.put("TimeStamp", String.valueOf(clock.instant().getEpochSecond())); fields.put("CheckMacValue", signer.sign(fields, attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot())); return fields; } private void validateResponse(Map response, OmgPaymentAttempt attempt) { if (!REQUIRED_FIELDS.stream().allMatch(response::containsKey)) { throw new IllegalArgumentException("OMG query response has missing fields"); } String checkMacValue = response.get("CheckMacValue"); if (checkMacValue == null || !checkMacValue.matches("(?i)[0-9a-f]{64}")) { throw new IllegalArgumentException("OMG query response signature is malformed"); } LinkedHashMap signingFields = new LinkedHashMap<>(response); signingFields.remove("CheckMacValue"); String expected = signer.sign(signingFields, attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()); if (!OmgPaymentNotifyService.secureEquals(expected, checkMacValue)) { throw new IllegalArgumentException("OMG query response signature mismatch"); } if (!attempt.getMerchantId().equals(response.get("MerchantID")) || !attempt.getMerchantTradeNo().equals(response.get("MerchantTradeNo")) || !String.valueOf(attempt.getAmount()).equals(response.get("TradeAmt"))) { throw new IllegalArgumentException("OMG query response identity mismatch"); } } private String synchronizeIfFinal(String tradeStatus, Map fields, OmgPaymentAttempt attempt) { if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == ATTEMPT_PAID && !TRADE_PAID.equals(tradeStatus)) { log.error("OMG query cannot downgrade paid attempt merchantTradeNo={}, gatewayTradeStatus={}", OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), tradeStatus); return "PAID"; } if (TRADE_UNPAID.equals(tradeStatus)) { return "UNPAID"; } if (!TRADE_PAID.equals(tradeStatus) && !TRADE_FAILED.equals(tradeStatus)) { return "UNKNOWN"; } OmgPaymentSettlementResult result = settlement.synchronizeVerifiedQuery( toGatewayFacts(fields, attempt, Integer.parseInt(tradeStatus))); return result.name(); } private static OmgPaymentGatewayFacts toGatewayFacts(Map fields, OmgPaymentAttempt attempt, int tradeStatus) { boolean paid = tradeStatus == 1; String tradeNo = blankToNull(fields.get("TradeNo")); if (paid && tradeNo == null) { throw new IllegalArgumentException("paid query response has no TradeNo"); } String paymentType = blankToNull(fields.get("PaymentType")); if (paid && paymentType == null) { throw new IllegalArgumentException("paid query response has no PaymentType"); } BigDecimal fee = parseDecimal(fields.get("PaymentTypeChargeFee")); if (fee == null || fee.signum() < 0) { throw new IllegalArgumentException("invalid payment fee"); } return new OmgPaymentGatewayFacts(OmgPaymentFactSource.QUERY, attempt.getMerchantId(), attempt.getMerchantTradeNo(), attempt.getAmount(), tradeStatus, "TradeStatus=" + tradeStatus, tradeNo, paymentType, parseDate(fields.get("PaymentDate"), paid), parseDate(fields.get("TradeDate"), true), fee, null); } private static OmgQueryPaymentResponse toResponse(String status, Map fields) { return new OmgQueryPaymentResponse(status, fields.get("TradeStatus"), fields.get("MerchantTradeNo"), blankToNull(fields.get("TradeNo")), Integer.valueOf(fields.get("TradeAmt")), blankToNull(fields.get("PaymentDate")), blankToNull(fields.get("TradeDate")), blankToNull(fields.get("PaymentType")), fields.get("HandlingCharge"), fields.get("PaymentTypeChargeFee")); } private static void validateAttempt(OmgPaymentAttempt attempt, Long storeId) { if (attempt == null) { throw business(PAYMENT_QUERY_NOT_AVAILABLE, storeId); } if (isBlank(attempt.getMerchantId()) || isBlank(attempt.getMerchantTradeNo()) || attempt.getAmount() == null || attempt.getAmount() <= 0 || isBlank(attempt.getHashKeySnapshot()) || isBlank(attempt.getHashIvSnapshot())) { throw business(PAYMENT_QUERY_FAILED, storeId); } } private static String normalizeOrderId(String orderId) { if (isBlank(orderId) || orderId.trim().length() > 64) { throw business(ORDER_REQUIRED); } return orderId.trim(); } private static Date parseDate(String value, boolean required) { if (isBlank(value)) { if (required) { throw new IllegalArgumentException("required OMG date is missing"); } return null; } try { return Date.from(LocalDateTime.parse(value, OMG_DATE).atZone(TAIPEI).toInstant()); } catch (DateTimeParseException exception) { throw new IllegalArgumentException("invalid OMG date", exception); } } private static BigDecimal parseDecimal(String value) { try { return isBlank(value) ? null : new BigDecimal(value); } catch (NumberFormatException exception) { throw new IllegalArgumentException("invalid OMG decimal", exception); } } private static String blankToNull(String value) { return isBlank(value) ? null : value; } private static String maskTradeNo(String tradeNo) { if (isBlank(tradeNo)) { return ""; } return tradeNo.length() <= 6 ? "***" : "***" + tradeNo.substring(tradeNo.length() - 6); } private static boolean isBlank(String value) { return value == null || value.isBlank(); } private static OmgPaymentBusinessException business(OmgPaymentErrorCode code) { return new OmgPaymentBusinessException(code); } private static OmgPaymentBusinessException business(OmgPaymentErrorCode code, Long storeId) { return new OmgPaymentBusinessException(code, storeId); } }