OmgPaymentNotifyService.java 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. package com.ruoyi.app.omgpay;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.ruoyi.app.order.DeliveryOrderNotificationService;
  4. import com.ruoyi.app.order.MerchantNotificationRouter;
  5. import com.ruoyi.app.order.dto.OrderPushBodyDto;
  6. import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
  7. import com.ruoyi.system.domain.PosOrder;
  8. import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
  9. import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
  10. import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
  11. import com.ruoyi.system.service.IPosOrderService;
  12. import org.springframework.beans.factory.annotation.Autowired;
  13. import org.slf4j.Logger;
  14. import org.slf4j.LoggerFactory;
  15. import org.springframework.stereotype.Service;
  16. import org.springframework.transaction.annotation.Transactional;
  17. import java.math.BigDecimal;
  18. import java.nio.charset.StandardCharsets;
  19. import java.security.MessageDigest;
  20. import java.time.LocalDateTime;
  21. import java.time.ZoneId;
  22. import java.time.format.DateTimeFormatter;
  23. import java.time.format.DateTimeParseException;
  24. import java.util.Date;
  25. import java.util.List;
  26. /** Verifies and applies one final OMG payment notification transactionally. */
  27. @Service
  28. public class OmgPaymentNotifyService {
  29. private static final Logger log = LoggerFactory.getLogger(OmgPaymentNotifyService.class);
  30. private static final int STATUS_PAID = 1;
  31. private static final int STATUS_SUPERSEDED = 3;
  32. private static final DateTimeFormatter OMG_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
  33. private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
  34. private static final List<String> REQUIRED_FIELDS = List.of(
  35. "MerchantID", "MerchantTradeNo", "StoreID", "RtnCode", "RtnMsg", "TradeNo", "TradeAmt",
  36. "PaymentDate", "PaymentType", "PaymentTypeChargeFee", "TradeDate", "SimulatePaid",
  37. "CustomField1", "CustomField2", "CustomField3", "CustomField4", "CheckMacValue");
  38. private final IOmgPaymentAttemptService attempts;
  39. private final OmgCheckMacSigner signer;
  40. @Autowired(required = false)
  41. private IPosOrderService posOrderService;
  42. @Autowired(required = false)
  43. private DeliveryOrderNotificationService deliveryOrderNotificationService;
  44. @Autowired(required = false)
  45. private MerchantNotificationRouter merchantNotificationRouter;
  46. public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
  47. this.attempts = attempts;
  48. this.signer = signer;
  49. }
  50. @Transactional(rollbackFor = Exception.class)
  51. public boolean process(OmgNotifyRequest request) {
  52. if (request == null || !request.isValid() || !hasRequiredFields(request)) {
  53. log.warn("OMG notify rejected reason=malformed_or_missing_fields");
  54. return false;
  55. }
  56. String merchantTradeNo = request.value("MerchantTradeNo");
  57. if (isBlank(merchantTradeNo) || merchantTradeNo.length() > 20) {
  58. log.warn("OMG notify rejected reason=invalid_merchant_trade_no");
  59. return false;
  60. }
  61. OmgPaymentAttempt discovered = attempts.selectByMerchantTradeNo(merchantTradeNo);
  62. if (discovered == null) {
  63. log.warn("OMG notify rejected merchantTradeNo={} reason=attempt_not_found", merchantTradeNo);
  64. return false;
  65. }
  66. if (factsDoNotIdentifySameAttempt(request, discovered)) {
  67. log.warn("OMG notify rejected merchantTradeNo={} reason=prelock_fact_mismatch", merchantTradeNo);
  68. return false;
  69. }
  70. OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId());
  71. OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(merchantTradeNo);
  72. if (attempt == null || !verifyTrust(request, attempt)) {
  73. log.warn("OMG notify rejected merchantTradeNo={} reason=trust_validation_failed", merchantTradeNo);
  74. return false;
  75. }
  76. ParsedFacts facts;
  77. try {
  78. facts = parseFacts(request);
  79. } catch (IllegalArgumentException exception) {
  80. log.warn("OMG notify rejected merchantTradeNo={} reason=invalid_gateway_facts", merchantTradeNo);
  81. return false;
  82. }
  83. OmgPaymentGatewayFacts gatewayFacts = new OmgPaymentGatewayFacts(
  84. OmgPaymentFactSource.NOTIFY, attempt.getMerchantId(), merchantTradeNo,
  85. attempt.getAmount(), facts.rtnCode, facts.rtnMsg, facts.tradeNo,
  86. facts.paymentType, facts.paymentDate, facts.tradeDate,
  87. facts.paymentTypeChargeFee, facts.simulatePaid);
  88. return applyVerifiedFacts(order, attempt, gatewayFacts) != null;
  89. }
  90. /**
  91. * Applies a signed QueryTradeInfo result so a lost callback cannot leave a paid order unpaid.
  92. * The row locks and irreversible state rules are identical to the callback path.
  93. */
  94. @Transactional(rollbackFor = Exception.class)
  95. public OmgPaymentSettlementResult synchronizeVerifiedQuery(OmgPaymentGatewayFacts facts) {
  96. if (facts == null || facts.source() != OmgPaymentFactSource.QUERY) {
  97. throw new IllegalArgumentException("verified OMG query facts are required");
  98. }
  99. OmgPaymentAttempt discovered = attempts.selectByMerchantTradeNo(facts.merchantTradeNo());
  100. if (discovered == null || !sameIdentity(facts, discovered)) {
  101. throw new IllegalArgumentException("OMG query facts do not identify an attempt");
  102. }
  103. OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId());
  104. OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(facts.merchantTradeNo());
  105. if (attempt == null || !sameIdentity(facts, attempt)) {
  106. throw new IllegalArgumentException("OMG query attempt changed while locking");
  107. }
  108. return applyVerifiedFacts(order, attempt, facts);
  109. }
  110. private OmgPaymentSettlementResult applyVerifiedFacts(OmgPaymentOrderSnapshot order,
  111. OmgPaymentAttempt attempt,
  112. OmgPaymentGatewayFacts facts) {
  113. boolean paid = facts.resultCode() == 1;
  114. if (!paid) {
  115. if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
  116. log.warn("OMG {} ignored late failure merchantTradeNo={}, code={}, message={}",
  117. facts.source(), attempt.getMerchantTradeNo(), facts.resultCode(), facts.resultMessage());
  118. return OmgPaymentSettlementResult.PAID;
  119. }
  120. if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_SUPERSEDED) {
  121. log.warn("OMG {} accepted failure for superseded attempt merchantTradeNo={}, code={}",
  122. facts.source(), attempt.getMerchantTradeNo(), facts.resultCode());
  123. return OmgPaymentSettlementResult.FAILED;
  124. }
  125. requireSingleUpdate(attempts.markFailed(toUpdate(attempt, facts)), "mark failed");
  126. log.warn("OMG payment failed source={}, merchantTradeNo={}, tradeNo={}, code={}, message={}",
  127. facts.source(), attempt.getMerchantTradeNo(), facts.tradeNo(),
  128. facts.resultCode(), facts.resultMessage());
  129. return OmgPaymentSettlementResult.FAILED;
  130. }
  131. if (order == null) {
  132. throw new IllegalStateException("OMG paid fact has no order");
  133. }
  134. if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
  135. if (attempt.getTradeNo() != null && !attempt.getTradeNo().equals(facts.tradeNo())) {
  136. throw new IllegalStateException("OMG paid fact conflicts with stored TradeNo");
  137. }
  138. log.info("OMG paid duplicate accepted source={}, merchantTradeNo={}, tradeNo={}",
  139. facts.source(), attempt.getMerchantTradeNo(), facts.tradeNo());
  140. return OmgPaymentSettlementResult.PAID;
  141. }
  142. requireSingleUpdate(attempts.markPaid(toUpdate(attempt, facts)), "mark paid");
  143. attempts.supersedeOtherCreated(attempt.getDdId(), attempt.getId());
  144. if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
  145. requireSingleUpdate(attempts.markOrderPaid(attempt.getDdId()), "mark order paid");
  146. openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
  147. }
  148. int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
  149. if (order.getState() != null && order.getState() == 4L) {
  150. log.error("OMG paid after order cancellation source={}, orderId={}, merchantTradeNo={}, tradeNo={}",
  151. facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(), facts.tradeNo());
  152. }
  153. if (otherPaid > 0) {
  154. log.error("OMG multiple paid attempts source={}, orderId={}, merchantTradeNo={}, "
  155. + "tradeNo={}, otherPaidCount={}",
  156. facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(),
  157. facts.tradeNo(), otherPaid);
  158. }
  159. log.info("OMG payment marked paid source={}, orderId={}, merchantTradeNo={}, tradeNo={}, amount={}",
  160. facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(),
  161. facts.tradeNo(), attempt.getAmount());
  162. return OmgPaymentSettlementResult.PAID;
  163. }
  164. private void openDeliveryOrderToRiders(String ddId, Long state) {
  165. if (Long.valueOf(4L).equals(state) || posOrderService == null
  166. || (deliveryOrderNotificationService == null && merchantNotificationRouter == null)) {
  167. return;
  168. }
  169. PosOrder paidOrder = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>()
  170. .eq(PosOrder::getDdId, ddId));
  171. if (paidOrder == null) {
  172. return;
  173. }
  174. paidOrder.setPayStatus(1L);
  175. if (Long.valueOf(0L).equals(paidOrder.getType()) && deliveryOrderNotificationService != null) {
  176. deliveryOrderNotificationService.notifyOrderAvailable(paidOrder);
  177. } else if (merchantNotificationRouter != null) {
  178. String orderNo = String.valueOf(paidOrder.getDdId());
  179. String body = OrderPushBodyDto.getJson(orderNo, String.valueOf(paidOrder.getState()), 0);
  180. merchantNotificationRouter.sendStoreNotification(paidOrder.getMdId(), paidOrder.getShId(),
  181. "no.message.push.message", "no.message.push.new.order", body, orderNo);
  182. }
  183. }
  184. private boolean verifyTrust(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
  185. if (!request.value("MerchantID").equals(attempt.getMerchantId())) {
  186. return false;
  187. }
  188. Integer amount = parseInteger(request.value("TradeAmt"));
  189. if (amount == null || !amount.equals(attempt.getAmount())) {
  190. return false;
  191. }
  192. String actual = request.value("CheckMacValue");
  193. if (actual == null || !actual.matches("(?i)[0-9a-f]{64}")) {
  194. return false;
  195. }
  196. String expected = signer.sign(request.signingFields(),
  197. attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot());
  198. return secureEquals(expected, actual);
  199. }
  200. private static boolean factsDoNotIdentifySameAttempt(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
  201. Integer amount = parseInteger(request.value("TradeAmt"));
  202. return !request.value("MerchantID").equals(attempt.getMerchantId())
  203. || amount == null || !amount.equals(attempt.getAmount());
  204. }
  205. private static boolean hasRequiredFields(OmgNotifyRequest request) {
  206. return REQUIRED_FIELDS.stream().allMatch(request::contains);
  207. }
  208. private static ParsedFacts parseFacts(OmgNotifyRequest request) {
  209. Integer rtnCode = requiredInteger(request.value("RtnCode"));
  210. BigDecimal fee = requiredDecimal(request.value("PaymentTypeChargeFee"));
  211. if (fee.signum() < 0) {
  212. throw new IllegalArgumentException("invalid PaymentTypeChargeFee");
  213. }
  214. Integer simulatePaid = requiredInteger(request.value("SimulatePaid"));
  215. if (simulatePaid != 0 && simulatePaid != 1) {
  216. throw new IllegalArgumentException("invalid SimulatePaid");
  217. }
  218. String tradeNo = request.value("TradeNo");
  219. if ((rtnCode == 1 && isBlank(tradeNo)) || (tradeNo != null && tradeNo.length() > 20)) {
  220. throw new IllegalArgumentException("invalid TradeNo");
  221. }
  222. if (isBlank(tradeNo)) {
  223. tradeNo = null;
  224. }
  225. String paymentType = request.value("PaymentType");
  226. if (isBlank(paymentType) || paymentType.length() > 20) {
  227. throw new IllegalArgumentException("invalid PaymentType");
  228. }
  229. Date paymentDate = parseDate(request.value("PaymentDate"), rtnCode == 1);
  230. Date tradeDate = parseDate(request.value("TradeDate"), true);
  231. String rtnMsg = request.value("RtnMsg");
  232. if (rtnMsg == null || rtnMsg.length() > 200) {
  233. throw new IllegalArgumentException("invalid RtnMsg");
  234. }
  235. return new ParsedFacts(rtnCode, rtnMsg, tradeNo, paymentType,
  236. paymentDate, tradeDate, fee, simulatePaid);
  237. }
  238. private static OmgPaymentAttempt toUpdate(OmgPaymentAttempt attempt, OmgPaymentGatewayFacts facts) {
  239. OmgPaymentAttempt update = new OmgPaymentAttempt();
  240. update.setId(attempt.getId());
  241. update.setTradeNo(facts.tradeNo());
  242. update.setRtnCode(facts.resultCode());
  243. update.setRtnMsg(limit(facts.resultMessage(), 200));
  244. update.setPaymentType(limit(facts.paymentType(), 20));
  245. update.setPaymentDate(facts.paymentDate());
  246. update.setTradeDate(facts.tradeDate());
  247. update.setPaymentTypeChargeFee(facts.paymentTypeChargeFee());
  248. update.setSimulatePaid(facts.simulatePaid());
  249. update.setLastNotifyTime(new Date());
  250. update.setUpdateTime(new Date());
  251. return update;
  252. }
  253. private static boolean sameIdentity(OmgPaymentGatewayFacts facts, OmgPaymentAttempt attempt) {
  254. return facts.merchantTradeNo().equals(attempt.getMerchantTradeNo())
  255. && facts.merchantId().equals(attempt.getMerchantId())
  256. && facts.amount() == attempt.getAmount();
  257. }
  258. static boolean secureEquals(String expected, String actual) {
  259. return expected != null && actual != null
  260. && MessageDigest.isEqual(expected.toUpperCase().getBytes(StandardCharsets.US_ASCII),
  261. actual.toUpperCase().getBytes(StandardCharsets.US_ASCII));
  262. }
  263. private static Date parseDate(String value, boolean required) {
  264. if (isBlank(value)) {
  265. if (required) {
  266. throw new IllegalArgumentException("required date missing");
  267. }
  268. return null;
  269. }
  270. try {
  271. return Date.from(LocalDateTime.parse(value, OMG_DATE).atZone(TAIPEI).toInstant());
  272. } catch (DateTimeParseException exception) {
  273. throw new IllegalArgumentException("invalid date", exception);
  274. }
  275. }
  276. private static Integer requiredInteger(String value) {
  277. Integer parsed = parseInteger(value);
  278. if (parsed == null) {
  279. throw new IllegalArgumentException("invalid integer");
  280. }
  281. return parsed;
  282. }
  283. private static BigDecimal requiredDecimal(String value) {
  284. if (isBlank(value)) {
  285. throw new IllegalArgumentException("invalid decimal");
  286. }
  287. try {
  288. return new BigDecimal(value);
  289. } catch (NumberFormatException exception) {
  290. throw new IllegalArgumentException("invalid decimal", exception);
  291. }
  292. }
  293. private static Integer parseInteger(String value) {
  294. try {
  295. return isBlank(value) ? null : Integer.valueOf(value);
  296. } catch (NumberFormatException exception) {
  297. return null;
  298. }
  299. }
  300. private static void requireSingleUpdate(int count, String action) {
  301. if (count != 1) {
  302. throw new IllegalStateException("OMG notify failed to " + action);
  303. }
  304. }
  305. private static boolean isBlank(String value) {
  306. return value == null || value.isBlank();
  307. }
  308. private static String limit(String value, int length) {
  309. return value == null || value.length() <= length ? value : value.substring(0, length);
  310. }
  311. private record ParsedFacts(int rtnCode, String rtnMsg, String tradeNo, String paymentType,
  312. Date paymentDate, Date tradeDate,
  313. BigDecimal paymentTypeChargeFee, int simulatePaid) {
  314. }
  315. }