OmgPaymentNotifyService.java 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  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.app.utils.PayPush;
  8. import com.ruoyi.app.utils.event.PushEventService;
  9. import com.ruoyi.common.utils.LocaleUtils;
  10. import com.ruoyi.common.utils.MessageUtils;
  11. import com.ruoyi.system.domain.InfoUser;
  12. import com.ruoyi.system.domain.PosOrder;
  13. import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
  14. import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
  15. import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
  16. import com.ruoyi.system.service.IInfoUserService;
  17. import com.ruoyi.system.service.IPosOrderService;
  18. import org.springframework.beans.factory.annotation.Autowired;
  19. import org.slf4j.Logger;
  20. import org.slf4j.LoggerFactory;
  21. import org.springframework.stereotype.Service;
  22. import org.springframework.transaction.annotation.Transactional;
  23. import org.springframework.transaction.support.TransactionSynchronization;
  24. import org.springframework.transaction.support.TransactionSynchronizationManager;
  25. import java.math.BigDecimal;
  26. import java.nio.charset.StandardCharsets;
  27. import java.security.MessageDigest;
  28. import java.time.LocalDateTime;
  29. import java.time.ZoneId;
  30. import java.time.format.DateTimeFormatter;
  31. import java.time.format.DateTimeParseException;
  32. import java.util.Date;
  33. import java.util.List;
  34. import java.util.Locale;
  35. /** Verifies and applies one final OMG payment notification transactionally. */
  36. @Service
  37. public class OmgPaymentNotifyService {
  38. private static final Logger log = LoggerFactory.getLogger(OmgPaymentNotifyService.class);
  39. private static final int STATUS_PAID = 1;
  40. private static final int STATUS_SUPERSEDED = 3;
  41. private static final DateTimeFormatter OMG_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
  42. private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
  43. private static final List<String> REQUIRED_FIELDS = List.of(
  44. "MerchantID", "MerchantTradeNo", "StoreID", "RtnCode", "RtnMsg", "TradeNo", "TradeAmt",
  45. "PaymentDate", "PaymentType", "PaymentTypeChargeFee", "TradeDate", "SimulatePaid",
  46. "CustomField1", "CustomField2", "CustomField3", "CustomField4", "CheckMacValue");
  47. private final IOmgPaymentAttemptService attempts;
  48. private final OmgCheckMacSigner signer;
  49. @Autowired(required = false)
  50. private IPosOrderService posOrderService;
  51. @Autowired(required = false)
  52. private DeliveryOrderNotificationService deliveryOrderNotificationService;
  53. @Autowired(required = false)
  54. private MerchantNotificationRouter merchantNotificationRouter;
  55. @Autowired(required = false)
  56. private IInfoUserService infoUserService;
  57. @Autowired(required = false)
  58. private PushEventService pushEventService;
  59. public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
  60. this.attempts = attempts;
  61. this.signer = signer;
  62. }
  63. @Transactional(rollbackFor = Exception.class)
  64. public boolean process(OmgNotifyRequest request) {
  65. if (request == null || !request.isValid() || !hasRequiredFields(request)) {
  66. log.warn("OMG notify rejected reason=malformed_or_missing_fields");
  67. return false;
  68. }
  69. String merchantTradeNo = request.value("MerchantTradeNo");
  70. if (isBlank(merchantTradeNo) || merchantTradeNo.length() > 20) {
  71. log.warn("OMG notify rejected reason=invalid_merchant_trade_no");
  72. return false;
  73. }
  74. OmgPaymentAttempt discovered = attempts.selectByMerchantTradeNo(merchantTradeNo);
  75. if (discovered == null) {
  76. log.warn("OMG notify rejected merchantTradeNo={} reason=attempt_not_found", merchantTradeNo);
  77. return false;
  78. }
  79. if (factsDoNotIdentifySameAttempt(request, discovered)) {
  80. log.warn("OMG notify rejected merchantTradeNo={} reason=prelock_fact_mismatch", merchantTradeNo);
  81. return false;
  82. }
  83. OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId());
  84. OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(merchantTradeNo);
  85. if (attempt == null || !verifyTrust(request, attempt)) {
  86. log.warn("OMG notify rejected merchantTradeNo={} reason=trust_validation_failed", merchantTradeNo);
  87. return false;
  88. }
  89. ParsedFacts facts;
  90. try {
  91. facts = parseFacts(request);
  92. } catch (IllegalArgumentException exception) {
  93. log.warn("OMG notify rejected merchantTradeNo={} reason=invalid_gateway_facts", merchantTradeNo);
  94. return false;
  95. }
  96. OmgPaymentGatewayFacts gatewayFacts = new OmgPaymentGatewayFacts(
  97. OmgPaymentFactSource.NOTIFY, attempt.getMerchantId(), merchantTradeNo,
  98. attempt.getAmount(), facts.rtnCode, facts.rtnMsg, facts.tradeNo,
  99. facts.paymentType, facts.paymentDate, facts.tradeDate,
  100. facts.paymentTypeChargeFee, facts.simulatePaid);
  101. return applyVerifiedFacts(order, attempt, gatewayFacts) != null;
  102. }
  103. /**
  104. * Applies a signed QueryTradeInfo result so a lost callback cannot leave a paid order unpaid.
  105. * The row locks and irreversible state rules are identical to the callback path.
  106. */
  107. @Transactional(rollbackFor = Exception.class)
  108. public OmgPaymentSettlementResult synchronizeVerifiedQuery(OmgPaymentGatewayFacts facts) {
  109. if (facts == null || facts.source() != OmgPaymentFactSource.QUERY) {
  110. throw new IllegalArgumentException("verified OMG query facts are required");
  111. }
  112. OmgPaymentAttempt discovered = attempts.selectByMerchantTradeNo(facts.merchantTradeNo());
  113. if (discovered == null || !sameIdentity(facts, discovered)) {
  114. throw new IllegalArgumentException("OMG query facts do not identify an attempt");
  115. }
  116. OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId());
  117. OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(facts.merchantTradeNo());
  118. if (attempt == null || !sameIdentity(facts, attempt)) {
  119. throw new IllegalArgumentException("OMG query attempt changed while locking");
  120. }
  121. return applyVerifiedFacts(order, attempt, facts);
  122. }
  123. private OmgPaymentSettlementResult applyVerifiedFacts(OmgPaymentOrderSnapshot order,
  124. OmgPaymentAttempt attempt,
  125. OmgPaymentGatewayFacts facts) {
  126. boolean paid = facts.resultCode() == 1;
  127. if (!paid) {
  128. if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
  129. log.warn("OMG {} ignored late failure merchantTradeNo={}, code={}, message={}",
  130. facts.source(), attempt.getMerchantTradeNo(), facts.resultCode(), facts.resultMessage());
  131. return OmgPaymentSettlementResult.PAID;
  132. }
  133. if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_SUPERSEDED) {
  134. log.warn("OMG {} accepted failure for superseded attempt merchantTradeNo={}, code={}",
  135. facts.source(), attempt.getMerchantTradeNo(), facts.resultCode());
  136. return OmgPaymentSettlementResult.FAILED;
  137. }
  138. requireSingleUpdate(attempts.markFailed(toUpdate(attempt, facts)), "mark failed");
  139. log.warn("OMG payment failed source={}, merchantTradeNo={}, tradeNo={}, code={}, message={}",
  140. facts.source(), attempt.getMerchantTradeNo(), facts.tradeNo(),
  141. facts.resultCode(), facts.resultMessage());
  142. return OmgPaymentSettlementResult.FAILED;
  143. }
  144. if (order == null) {
  145. throw new IllegalStateException("OMG paid fact has no order");
  146. }
  147. if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
  148. if (attempt.getTradeNo() != null && !attempt.getTradeNo().equals(facts.tradeNo())) {
  149. throw new IllegalStateException("OMG paid fact conflicts with stored TradeNo");
  150. }
  151. log.info("OMG paid duplicate accepted source={}, merchantTradeNo={}, tradeNo={}",
  152. facts.source(), attempt.getMerchantTradeNo(), facts.tradeNo());
  153. return OmgPaymentSettlementResult.PAID;
  154. }
  155. requireSingleUpdate(attempts.markPaid(toUpdate(attempt, facts)), "mark paid");
  156. attempts.supersedeOtherCreated(attempt.getDdId(), attempt.getId());
  157. if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
  158. requireSingleUpdate(attempts.markOrderPaid(attempt.getDdId()), "mark order paid");
  159. openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
  160. pushPaymentSuccessToUser(order);
  161. }
  162. int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
  163. if (order.getState() != null && order.getState() == 4L) {
  164. log.error("OMG paid after order cancellation source={}, orderId={}, merchantTradeNo={}, tradeNo={}",
  165. facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(), facts.tradeNo());
  166. }
  167. if (otherPaid > 0) {
  168. log.error("OMG multiple paid attempts source={}, orderId={}, merchantTradeNo={}, "
  169. + "tradeNo={}, otherPaidCount={}",
  170. facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(),
  171. facts.tradeNo(), otherPaid);
  172. }
  173. log.info("OMG payment marked paid source={}, orderId={}, merchantTradeNo={}, tradeNo={}, amount={}",
  174. facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(),
  175. facts.tradeNo(), attempt.getAmount());
  176. return OmgPaymentSettlementResult.PAID;
  177. }
  178. private void openDeliveryOrderToRiders(String ddId, Long state) {
  179. if (Long.valueOf(4L).equals(state) || posOrderService == null
  180. || (deliveryOrderNotificationService == null && merchantNotificationRouter == null)) {
  181. return;
  182. }
  183. PosOrder paidOrder = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>()
  184. .eq(PosOrder::getDdId, ddId));
  185. if (paidOrder == null) {
  186. return;
  187. }
  188. paidOrder.setPayStatus(1L);
  189. if (Long.valueOf(0L).equals(paidOrder.getType()) && deliveryOrderNotificationService != null) {
  190. deliveryOrderNotificationService.notifyOrderAvailable(paidOrder);
  191. } else if (merchantNotificationRouter != null) {
  192. String orderNo = String.valueOf(paidOrder.getDdId());
  193. String body = OrderPushBodyDto.getJson(orderNo, String.valueOf(paidOrder.getState()), 0);
  194. merchantNotificationRouter.sendStoreNotification(paidOrder.getMdId(), paidOrder.getShId(),
  195. "no.message.push.message", "no.message.push.new.order", body, orderNo);
  196. }
  197. }
  198. /**
  199. * 首次支付成功后给下单用户推"支付成功"(对齐 LINE Pay 链路);重复回调/补单查询
  200. * 已支付的订单不会重复推(payStatus 窗口保证)。先入消息中心库再发 iOS 推送,
  201. * 推送外呼失败不影响消息中心落库。
  202. */
  203. private void pushPaymentSuccessToUser(OmgPaymentOrderSnapshot order) {
  204. if (infoUserService == null || pushEventService == null
  205. || order == null || order.getUserId() == null) {
  206. return;
  207. }
  208. runAfterCommit(() -> {
  209. try {
  210. InfoUser user = infoUserService.getById(order.getUserId());
  211. if (user == null) {
  212. return;
  213. }
  214. // 回调线程无请求级 locale,必须按收件人用户语言解析(同 LINE Pay 链路)
  215. Locale userLocale = LocaleUtils.getUserLocale(user.getUserId());
  216. String title = MessageUtils.message("no.message.push.message", userLocale);
  217. String content = MessageUtils.message("no.message.push.payment.success", userLocale);
  218. String body = OrderPushBodyDto.getJson(order.getDdId(), String.valueOf(order.getState()), 0);
  219. pushEventService.PublisherEvent(user.getUserId(), title, content, body);
  220. if (user.getCid() != null && !user.getCid().trim().isEmpty()) {
  221. new PayPush().apppush(user.getCid(), title, content, body);
  222. }
  223. } catch (Exception exception) {
  224. log.error("OMG payment success push failed, ddId={}", order.getDdId(), exception);
  225. }
  226. });
  227. }
  228. private static void runAfterCommit(Runnable action) {
  229. if (!TransactionSynchronizationManager.isSynchronizationActive()) {
  230. action.run();
  231. return;
  232. }
  233. TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
  234. @Override
  235. public void afterCommit() {
  236. action.run();
  237. }
  238. });
  239. }
  240. private boolean verifyTrust(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
  241. if (!request.value("MerchantID").equals(attempt.getMerchantId())) {
  242. return false;
  243. }
  244. Integer amount = parseInteger(request.value("TradeAmt"));
  245. if (amount == null || !amount.equals(attempt.getAmount())) {
  246. return false;
  247. }
  248. String actual = request.value("CheckMacValue");
  249. if (actual == null || !actual.matches("(?i)[0-9a-f]{64}")) {
  250. return false;
  251. }
  252. String expected = signer.sign(request.signingFields(),
  253. attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot());
  254. return secureEquals(expected, actual);
  255. }
  256. private static boolean factsDoNotIdentifySameAttempt(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
  257. Integer amount = parseInteger(request.value("TradeAmt"));
  258. return !request.value("MerchantID").equals(attempt.getMerchantId())
  259. || amount == null || !amount.equals(attempt.getAmount());
  260. }
  261. private static boolean hasRequiredFields(OmgNotifyRequest request) {
  262. return REQUIRED_FIELDS.stream().allMatch(request::contains);
  263. }
  264. private static ParsedFacts parseFacts(OmgNotifyRequest request) {
  265. Integer rtnCode = requiredInteger(request.value("RtnCode"));
  266. BigDecimal fee = requiredDecimal(request.value("PaymentTypeChargeFee"));
  267. if (fee.signum() < 0) {
  268. throw new IllegalArgumentException("invalid PaymentTypeChargeFee");
  269. }
  270. Integer simulatePaid = requiredInteger(request.value("SimulatePaid"));
  271. if (simulatePaid != 0 && simulatePaid != 1) {
  272. throw new IllegalArgumentException("invalid SimulatePaid");
  273. }
  274. String tradeNo = request.value("TradeNo");
  275. if ((rtnCode == 1 && isBlank(tradeNo)) || (tradeNo != null && tradeNo.length() > 20)) {
  276. throw new IllegalArgumentException("invalid TradeNo");
  277. }
  278. if (isBlank(tradeNo)) {
  279. tradeNo = null;
  280. }
  281. String paymentType = request.value("PaymentType");
  282. if (isBlank(paymentType) || paymentType.length() > 20) {
  283. throw new IllegalArgumentException("invalid PaymentType");
  284. }
  285. Date paymentDate = parseDate(request.value("PaymentDate"), rtnCode == 1);
  286. Date tradeDate = parseDate(request.value("TradeDate"), true);
  287. String rtnMsg = request.value("RtnMsg");
  288. if (rtnMsg == null || rtnMsg.length() > 200) {
  289. throw new IllegalArgumentException("invalid RtnMsg");
  290. }
  291. return new ParsedFacts(rtnCode, rtnMsg, tradeNo, paymentType,
  292. paymentDate, tradeDate, fee, simulatePaid);
  293. }
  294. private static OmgPaymentAttempt toUpdate(OmgPaymentAttempt attempt, OmgPaymentGatewayFacts facts) {
  295. OmgPaymentAttempt update = new OmgPaymentAttempt();
  296. update.setId(attempt.getId());
  297. update.setTradeNo(facts.tradeNo());
  298. update.setRtnCode(facts.resultCode());
  299. update.setRtnMsg(limit(facts.resultMessage(), 200));
  300. update.setPaymentType(limit(facts.paymentType(), 20));
  301. update.setPaymentDate(facts.paymentDate());
  302. update.setTradeDate(facts.tradeDate());
  303. update.setPaymentTypeChargeFee(facts.paymentTypeChargeFee());
  304. update.setSimulatePaid(facts.simulatePaid());
  305. update.setLastNotifyTime(new Date());
  306. update.setUpdateTime(new Date());
  307. return update;
  308. }
  309. private static boolean sameIdentity(OmgPaymentGatewayFacts facts, OmgPaymentAttempt attempt) {
  310. return facts.merchantTradeNo().equals(attempt.getMerchantTradeNo())
  311. && facts.merchantId().equals(attempt.getMerchantId())
  312. && facts.amount() == attempt.getAmount();
  313. }
  314. static boolean secureEquals(String expected, String actual) {
  315. return expected != null && actual != null
  316. && MessageDigest.isEqual(expected.toUpperCase().getBytes(StandardCharsets.US_ASCII),
  317. actual.toUpperCase().getBytes(StandardCharsets.US_ASCII));
  318. }
  319. private static Date parseDate(String value, boolean required) {
  320. if (isBlank(value)) {
  321. if (required) {
  322. throw new IllegalArgumentException("required date missing");
  323. }
  324. return null;
  325. }
  326. try {
  327. return Date.from(LocalDateTime.parse(value, OMG_DATE).atZone(TAIPEI).toInstant());
  328. } catch (DateTimeParseException exception) {
  329. throw new IllegalArgumentException("invalid date", exception);
  330. }
  331. }
  332. private static Integer requiredInteger(String value) {
  333. Integer parsed = parseInteger(value);
  334. if (parsed == null) {
  335. throw new IllegalArgumentException("invalid integer");
  336. }
  337. return parsed;
  338. }
  339. private static BigDecimal requiredDecimal(String value) {
  340. if (isBlank(value)) {
  341. throw new IllegalArgumentException("invalid decimal");
  342. }
  343. try {
  344. return new BigDecimal(value);
  345. } catch (NumberFormatException exception) {
  346. throw new IllegalArgumentException("invalid decimal", exception);
  347. }
  348. }
  349. private static Integer parseInteger(String value) {
  350. try {
  351. return isBlank(value) ? null : Integer.valueOf(value);
  352. } catch (NumberFormatException exception) {
  353. return null;
  354. }
  355. }
  356. private static void requireSingleUpdate(int count, String action) {
  357. if (count != 1) {
  358. throw new IllegalStateException("OMG notify failed to " + action);
  359. }
  360. }
  361. private static boolean isBlank(String value) {
  362. return value == null || value.isBlank();
  363. }
  364. private static String limit(String value, int length) {
  365. return value == null || value.length() <= length ? value : value.substring(0, length);
  366. }
  367. private record ParsedFacts(int rtnCode, String rtnMsg, String tradeNo, String paymentType,
  368. Date paymentDate, Date tradeDate,
  369. BigDecimal paymentTypeChargeFee, int simulatePaid) {
  370. }
  371. }