package com.ruoyi.app.omgpay; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.ruoyi.app.order.DeliveryOrderNotificationService; import com.ruoyi.app.order.MerchantNotificationRouter; import com.ruoyi.app.order.dto.OrderPushBodyDto; import com.ruoyi.app.omgpay.dto.OmgNotifyRequest; import com.ruoyi.system.domain.PosOrder; import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt; import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot; import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService; import com.ruoyi.system.service.IPosOrderService; import org.springframework.beans.factory.annotation.Autowired; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; 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.List; /** Verifies and applies one final OMG payment notification transactionally. */ @Service public class OmgPaymentNotifyService { private static final Logger log = LoggerFactory.getLogger(OmgPaymentNotifyService.class); private static final int STATUS_PAID = 1; private static final int STATUS_SUPERSEDED = 3; 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", "RtnCode", "RtnMsg", "TradeNo", "TradeAmt", "PaymentDate", "PaymentType", "PaymentTypeChargeFee", "TradeDate", "SimulatePaid", "CustomField1", "CustomField2", "CustomField3", "CustomField4", "CheckMacValue"); private final IOmgPaymentAttemptService attempts; private final OmgCheckMacSigner signer; @Autowired(required = false) private IPosOrderService posOrderService; @Autowired(required = false) private DeliveryOrderNotificationService deliveryOrderNotificationService; @Autowired(required = false) private MerchantNotificationRouter merchantNotificationRouter; public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) { this.attempts = attempts; this.signer = signer; } @Transactional(rollbackFor = Exception.class) public boolean process(OmgNotifyRequest request) { if (request == null || !request.isValid() || !hasRequiredFields(request)) { log.warn("OMG notify rejected reason=malformed_or_missing_fields"); return false; } String merchantTradeNo = request.value("MerchantTradeNo"); if (isBlank(merchantTradeNo) || merchantTradeNo.length() > 20) { log.warn("OMG notify rejected reason=invalid_merchant_trade_no"); return false; } OmgPaymentAttempt discovered = attempts.selectByMerchantTradeNo(merchantTradeNo); if (discovered == null) { log.warn("OMG notify rejected merchantTradeNo={} reason=attempt_not_found", merchantTradeNo); return false; } if (factsDoNotIdentifySameAttempt(request, discovered)) { log.warn("OMG notify rejected merchantTradeNo={} reason=prelock_fact_mismatch", merchantTradeNo); return false; } OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId()); OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(merchantTradeNo); if (attempt == null || !verifyTrust(request, attempt)) { log.warn("OMG notify rejected merchantTradeNo={} reason=trust_validation_failed", merchantTradeNo); return false; } ParsedFacts facts; try { facts = parseFacts(request); } catch (IllegalArgumentException exception) { log.warn("OMG notify rejected merchantTradeNo={} reason=invalid_gateway_facts", merchantTradeNo); return false; } OmgPaymentGatewayFacts gatewayFacts = new OmgPaymentGatewayFacts( OmgPaymentFactSource.NOTIFY, attempt.getMerchantId(), merchantTradeNo, attempt.getAmount(), facts.rtnCode, facts.rtnMsg, facts.tradeNo, facts.paymentType, facts.paymentDate, facts.tradeDate, facts.paymentTypeChargeFee, facts.simulatePaid); return applyVerifiedFacts(order, attempt, gatewayFacts) != null; } /** * Applies a signed QueryTradeInfo result so a lost callback cannot leave a paid order unpaid. * The row locks and irreversible state rules are identical to the callback path. */ @Transactional(rollbackFor = Exception.class) public OmgPaymentSettlementResult synchronizeVerifiedQuery(OmgPaymentGatewayFacts facts) { if (facts == null || facts.source() != OmgPaymentFactSource.QUERY) { throw new IllegalArgumentException("verified OMG query facts are required"); } OmgPaymentAttempt discovered = attempts.selectByMerchantTradeNo(facts.merchantTradeNo()); if (discovered == null || !sameIdentity(facts, discovered)) { throw new IllegalArgumentException("OMG query facts do not identify an attempt"); } OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId()); OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(facts.merchantTradeNo()); if (attempt == null || !sameIdentity(facts, attempt)) { throw new IllegalArgumentException("OMG query attempt changed while locking"); } return applyVerifiedFacts(order, attempt, facts); } private OmgPaymentSettlementResult applyVerifiedFacts(OmgPaymentOrderSnapshot order, OmgPaymentAttempt attempt, OmgPaymentGatewayFacts facts) { boolean paid = facts.resultCode() == 1; if (!paid) { if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) { log.warn("OMG {} ignored late failure merchantTradeNo={}, code={}, message={}", facts.source(), attempt.getMerchantTradeNo(), facts.resultCode(), facts.resultMessage()); return OmgPaymentSettlementResult.PAID; } if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_SUPERSEDED) { log.warn("OMG {} accepted failure for superseded attempt merchantTradeNo={}, code={}", facts.source(), attempt.getMerchantTradeNo(), facts.resultCode()); return OmgPaymentSettlementResult.FAILED; } requireSingleUpdate(attempts.markFailed(toUpdate(attempt, facts)), "mark failed"); log.warn("OMG payment failed source={}, merchantTradeNo={}, tradeNo={}, code={}, message={}", facts.source(), attempt.getMerchantTradeNo(), facts.tradeNo(), facts.resultCode(), facts.resultMessage()); return OmgPaymentSettlementResult.FAILED; } if (order == null) { throw new IllegalStateException("OMG paid fact has no order"); } if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) { if (attempt.getTradeNo() != null && !attempt.getTradeNo().equals(facts.tradeNo())) { throw new IllegalStateException("OMG paid fact conflicts with stored TradeNo"); } log.info("OMG paid duplicate accepted source={}, merchantTradeNo={}, tradeNo={}", facts.source(), attempt.getMerchantTradeNo(), facts.tradeNo()); return OmgPaymentSettlementResult.PAID; } requireSingleUpdate(attempts.markPaid(toUpdate(attempt, facts)), "mark paid"); attempts.supersedeOtherCreated(attempt.getDdId(), attempt.getId()); if (order.getPayStatus() == null || order.getPayStatus() != 1L) { requireSingleUpdate(attempts.markOrderPaid(attempt.getDdId()), "mark order paid"); openDeliveryOrderToRiders(attempt.getDdId(), order.getState()); } int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId()); if (order.getState() != null && order.getState() == 4L) { log.error("OMG paid after order cancellation source={}, orderId={}, merchantTradeNo={}, tradeNo={}", facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(), facts.tradeNo()); } if (otherPaid > 0) { log.error("OMG multiple paid attempts source={}, orderId={}, merchantTradeNo={}, " + "tradeNo={}, otherPaidCount={}", facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(), facts.tradeNo(), otherPaid); } log.info("OMG payment marked paid source={}, orderId={}, merchantTradeNo={}, tradeNo={}, amount={}", facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(), facts.tradeNo(), attempt.getAmount()); return OmgPaymentSettlementResult.PAID; } private void openDeliveryOrderToRiders(String ddId, Long state) { if (Long.valueOf(4L).equals(state) || posOrderService == null || (deliveryOrderNotificationService == null && merchantNotificationRouter == null)) { return; } PosOrder paidOrder = posOrderService.getOne(new LambdaQueryWrapper() .eq(PosOrder::getDdId, ddId)); if (paidOrder == null) { return; } paidOrder.setPayStatus(1L); if (Long.valueOf(0L).equals(paidOrder.getType()) && deliveryOrderNotificationService != null) { deliveryOrderNotificationService.notifyOrderAvailable(paidOrder); } else if (merchantNotificationRouter != null) { String orderNo = String.valueOf(paidOrder.getDdId()); String body = OrderPushBodyDto.getJson(orderNo, String.valueOf(paidOrder.getState()), 0); merchantNotificationRouter.sendStoreNotification(paidOrder.getMdId(), paidOrder.getShId(), "no.message.push.message", "no.message.push.new.order", body, orderNo); } } private boolean verifyTrust(OmgNotifyRequest request, OmgPaymentAttempt attempt) { if (!request.value("MerchantID").equals(attempt.getMerchantId())) { return false; } Integer amount = parseInteger(request.value("TradeAmt")); if (amount == null || !amount.equals(attempt.getAmount())) { return false; } String actual = request.value("CheckMacValue"); if (actual == null || !actual.matches("(?i)[0-9a-f]{64}")) { return false; } String expected = signer.sign(request.signingFields(), attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()); return secureEquals(expected, actual); } private static boolean factsDoNotIdentifySameAttempt(OmgNotifyRequest request, OmgPaymentAttempt attempt) { Integer amount = parseInteger(request.value("TradeAmt")); return !request.value("MerchantID").equals(attempt.getMerchantId()) || amount == null || !amount.equals(attempt.getAmount()); } private static boolean hasRequiredFields(OmgNotifyRequest request) { return REQUIRED_FIELDS.stream().allMatch(request::contains); } private static ParsedFacts parseFacts(OmgNotifyRequest request) { Integer rtnCode = requiredInteger(request.value("RtnCode")); BigDecimal fee = requiredDecimal(request.value("PaymentTypeChargeFee")); if (fee.signum() < 0) { throw new IllegalArgumentException("invalid PaymentTypeChargeFee"); } Integer simulatePaid = requiredInteger(request.value("SimulatePaid")); if (simulatePaid != 0 && simulatePaid != 1) { throw new IllegalArgumentException("invalid SimulatePaid"); } String tradeNo = request.value("TradeNo"); if ((rtnCode == 1 && isBlank(tradeNo)) || (tradeNo != null && tradeNo.length() > 20)) { throw new IllegalArgumentException("invalid TradeNo"); } if (isBlank(tradeNo)) { tradeNo = null; } String paymentType = request.value("PaymentType"); if (isBlank(paymentType) || paymentType.length() > 20) { throw new IllegalArgumentException("invalid PaymentType"); } Date paymentDate = parseDate(request.value("PaymentDate"), rtnCode == 1); Date tradeDate = parseDate(request.value("TradeDate"), true); String rtnMsg = request.value("RtnMsg"); if (rtnMsg == null || rtnMsg.length() > 200) { throw new IllegalArgumentException("invalid RtnMsg"); } return new ParsedFacts(rtnCode, rtnMsg, tradeNo, paymentType, paymentDate, tradeDate, fee, simulatePaid); } private static OmgPaymentAttempt toUpdate(OmgPaymentAttempt attempt, OmgPaymentGatewayFacts facts) { OmgPaymentAttempt update = new OmgPaymentAttempt(); update.setId(attempt.getId()); update.setTradeNo(facts.tradeNo()); update.setRtnCode(facts.resultCode()); update.setRtnMsg(limit(facts.resultMessage(), 200)); update.setPaymentType(limit(facts.paymentType(), 20)); update.setPaymentDate(facts.paymentDate()); update.setTradeDate(facts.tradeDate()); update.setPaymentTypeChargeFee(facts.paymentTypeChargeFee()); update.setSimulatePaid(facts.simulatePaid()); update.setLastNotifyTime(new Date()); update.setUpdateTime(new Date()); return update; } private static boolean sameIdentity(OmgPaymentGatewayFacts facts, OmgPaymentAttempt attempt) { return facts.merchantTradeNo().equals(attempt.getMerchantTradeNo()) && facts.merchantId().equals(attempt.getMerchantId()) && facts.amount() == attempt.getAmount(); } static boolean secureEquals(String expected, String actual) { return expected != null && actual != null && MessageDigest.isEqual(expected.toUpperCase().getBytes(StandardCharsets.US_ASCII), actual.toUpperCase().getBytes(StandardCharsets.US_ASCII)); } private static Date parseDate(String value, boolean required) { if (isBlank(value)) { if (required) { throw new IllegalArgumentException("required date missing"); } return null; } try { return Date.from(LocalDateTime.parse(value, OMG_DATE).atZone(TAIPEI).toInstant()); } catch (DateTimeParseException exception) { throw new IllegalArgumentException("invalid date", exception); } } private static Integer requiredInteger(String value) { Integer parsed = parseInteger(value); if (parsed == null) { throw new IllegalArgumentException("invalid integer"); } return parsed; } private static BigDecimal requiredDecimal(String value) { if (isBlank(value)) { throw new IllegalArgumentException("invalid decimal"); } try { return new BigDecimal(value); } catch (NumberFormatException exception) { throw new IllegalArgumentException("invalid decimal", exception); } } private static Integer parseInteger(String value) { try { return isBlank(value) ? null : Integer.valueOf(value); } catch (NumberFormatException exception) { return null; } } private static void requireSingleUpdate(int count, String action) { if (count != 1) { throw new IllegalStateException("OMG notify failed to " + action); } } private static boolean isBlank(String value) { return value == null || value.isBlank(); } private static String limit(String value, int length) { return value == null || value.length() <= length ? value : value.substring(0, length); } private record ParsedFacts(int rtnCode, String rtnMsg, String tradeNo, String paymentType, Date paymentDate, Date tradeDate, BigDecimal paymentTypeChargeFee, int simulatePaid) { } }