|
@@ -0,0 +1,249 @@
|
|
|
|
|
+package com.ruoyi.app.omgpay;
|
|
|
|
|
+
|
|
|
|
|
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
|
|
|
|
|
+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.transaction.annotation.Transactional;
|
|
|
|
|
+
|
|
|
|
|
+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<String> 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;
|
|
|
|
|
+
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (facts.rtnCode != 1) {
|
|
|
|
|
+ if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
|
|
|
|
|
+ log.warn("OMG notify ignored late failure merchantTradeNo={}, rtnCode={}, rtnMsg={}",
|
|
|
|
|
+ merchantTradeNo, facts.rtnCode, facts.rtnMsg);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_SUPERSEDED) {
|
|
|
|
|
+ log.warn("OMG notify accepted failure for superseded attempt merchantTradeNo={}, "
|
|
|
|
|
+ + "rtnCode={}, rtnMsg={}", merchantTradeNo, facts.rtnCode, facts.rtnMsg);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ requireSingleUpdate(attempts.markFailed(toUpdate(attempt, facts)), "mark failed");
|
|
|
|
|
+ log.warn("OMG payment failed merchantTradeNo={}, tradeNo={}, rtnCode={}, rtnMsg={}",
|
|
|
|
|
+ merchantTradeNo, facts.tradeNo, facts.rtnCode, facts.rtnMsg);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (order == null) {
|
|
|
|
|
+ log.error("OMG paid notify rejected merchantTradeNo={} reason=order_not_found", merchantTradeNo);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
|
|
|
|
|
+ if (attempt.getTradeNo() != null && !attempt.getTradeNo().equals(facts.tradeNo)) {
|
|
|
|
|
+ log.error("OMG paid duplicate conflict merchantTradeNo={}, storedTradeNo={}, callbackTradeNo={}",
|
|
|
|
|
+ merchantTradeNo, attempt.getTradeNo(), facts.tradeNo);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("OMG paid duplicate accepted merchantTradeNo={}, tradeNo={}", merchantTradeNo, facts.tradeNo);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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");
|
|
|
|
|
+ }
|
|
|
|
|
+ int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
|
|
|
|
|
+ if (order.getState() != null && order.getState() == 4L) {
|
|
|
|
|
+ log.error("OMG paid after order cancellation orderId={}, merchantTradeNo={}, tradeNo={}, simulatePaid={}",
|
|
|
|
|
+ attempt.getDdId(), merchantTradeNo, facts.tradeNo, facts.simulatePaid);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (otherPaid > 0) {
|
|
|
|
|
+ log.error("OMG multiple paid attempts orderId={}, merchantTradeNo={}, tradeNo={}, otherPaidCount={}",
|
|
|
|
|
+ attempt.getDdId(), merchantTradeNo, facts.tradeNo, otherPaid);
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("OMG payment marked paid orderId={}, merchantTradeNo={}, tradeNo={}, amount={}, simulatePaid={}",
|
|
|
|
|
+ attempt.getDdId(), merchantTradeNo, facts.tradeNo, attempt.getAmount(), facts.simulatePaid);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
|
|
|
|
|
+ actual.toUpperCase().getBytes(StandardCharsets.US_ASCII));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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"));
|
|
|
|
|
+ Integer fee = requiredInteger(request.value("PaymentTypeChargeFee"));
|
|
|
|
|
+ if (fee < 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, ParsedFacts facts) {
|
|
|
|
|
+ OmgPaymentAttempt update = new OmgPaymentAttempt();
|
|
|
|
|
+ update.setId(attempt.getId());
|
|
|
|
|
+ update.setTradeNo(facts.tradeNo);
|
|
|
|
|
+ update.setRtnCode(facts.rtnCode);
|
|
|
|
|
+ update.setRtnMsg(limit(facts.rtnMsg, 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 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 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, int paymentTypeChargeFee, int simulatePaid) {
|
|
|
|
|
+ }
|
|
|
|
|
+}
|