OmgPayController.java 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109
  1. package com.ruoyi.app.pay;
  2. import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
  3. import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
  4. import com.alibaba.fastjson2.JSON;
  5. import com.ruoyi.app.order.dto.OrderPushBodyDto;
  6. import com.ruoyi.app.order.OrderLifecycleService;
  7. import com.ruoyi.app.pay.dto.OmgCallbackRequest;
  8. import com.ruoyi.app.pay.dto.OmgOrderRequest;
  9. import com.ruoyi.app.pay.dto.OmgRefundOutcome;
  10. import com.ruoyi.app.utils.PayPush;
  11. import com.ruoyi.app.utils.event.PushEventService;
  12. import com.ruoyi.app.utils.omg.OmgCheckMacValue;
  13. import com.ruoyi.app.utils.omg.OmgPay;
  14. import com.ruoyi.app.utils.omg.OmgPayConfig;
  15. import com.ruoyi.app.utils.omg.OmgQueryThrottle;
  16. import com.ruoyi.common.annotation.Anonymous;
  17. import com.ruoyi.common.annotation.RepeatSubmit;
  18. import com.ruoyi.common.core.controller.BaseController;
  19. import com.ruoyi.common.core.domain.AjaxResult;
  20. import com.ruoyi.common.utils.MessageUtils;
  21. import com.ruoyi.system.domain.InfoUser;
  22. import com.ruoyi.system.domain.IpnLog;
  23. import com.ruoyi.system.domain.PosOrder;
  24. import com.ruoyi.system.domain.PosOrderOmgPayment;
  25. import com.ruoyi.system.domain.PosStoreOmg;
  26. import com.ruoyi.system.service.IInfoUserService;
  27. import com.ruoyi.system.service.IIpnLogService;
  28. import com.ruoyi.system.service.IPosOrderOmgPaymentService;
  29. import com.ruoyi.system.service.IPosOrderOmgRefundService;
  30. import com.ruoyi.system.service.IPosOrderService;
  31. import com.ruoyi.system.service.IPosStoreOmgService;
  32. import com.ruoyi.system.utils.Auth;
  33. import com.ruoyi.system.utils.JwtUtil;
  34. import com.ruoyi.system.utils.OrderLogHelper;
  35. import jakarta.servlet.http.HttpServletResponse;
  36. import org.slf4j.Logger;
  37. import org.slf4j.LoggerFactory;
  38. import org.springframework.beans.factory.annotation.Autowired;
  39. import org.springframework.beans.factory.annotation.Value;
  40. import org.springframework.dao.DuplicateKeyException;
  41. import org.springframework.transaction.annotation.Transactional;
  42. import org.springframework.transaction.support.TransactionSynchronization;
  43. import org.springframework.transaction.support.TransactionSynchronizationManager;
  44. import org.springframework.web.bind.annotation.*;
  45. import java.io.IOException;
  46. import java.net.URLEncoder;
  47. import java.nio.charset.StandardCharsets;
  48. import java.text.SimpleDateFormat;
  49. import java.util.Date;
  50. import java.util.LinkedHashMap;
  51. import java.util.List;
  52. import java.util.Locale;
  53. import java.util.Map;
  54. import java.util.Set;
  55. import java.util.TimeZone;
  56. import java.util.UUID;
  57. /**
  58. * OMG(歐買尬/FunPoint) AIO 线上支付 Controller(独立于 newebpay,零蓝新依赖)。
  59. *
  60. * <p>本期实现:
  61. * <ul>
  62. * <li>{@code POST /pay/omg/create} — 发起 AIO 幕前支付(US1,@Auth)。返回含 gatewayUrl 的 form 字段,
  63. * 前端构建隐藏 form submit 到 OMG 收银台(ChoosePayment=ALL)。</li>
  64. * <li>{@code POST /pay/omg/notify} — OMG 服务端回调(US2,@Anonymous)。验签 CheckMacValue + 幂等 + 金额 +
  65. * RtnCode/SimulatePaid 校验 → markSuccess → 更新订单 payStatus=1 + 推送用户/商家 → 回纯串 {@code 1|OK}。</li>
  66. * <li>{@code GET|POST /pay/omg/return} — 完成页引导(US2,@Anonymous)。仅 302 回前端结果页,不改订单状态。</li>
  67. * </ul>
  68. *
  69. * @author ruoyi
  70. * @date 2026-07-29
  71. */
  72. @RestController
  73. @RequestMapping("/pay/omg")
  74. public class OmgPayController extends BaseController {
  75. private static final Logger log = LoggerFactory.getLogger(OmgPayController.class);
  76. /** payType 取值:OMG 在线支付(发起时写入 pos_order.pay_type;具体方式如 Credit_CreditCard、ATM 系列、CVS 系列等由回调写入 pos_order_omg_payment.pay_type)。 */
  77. public static final String PAY_TYPE_OMG = "2";
  78. @Autowired
  79. private IPosOrderService posOrderService;
  80. @Autowired
  81. private IPosStoreOmgService storeOmgService;
  82. @Autowired
  83. private IPosOrderOmgPaymentService paymentService;
  84. @Autowired
  85. private OmgPay omgPay;
  86. @Autowired
  87. private IIpnLogService ipnLogService;
  88. @Autowired
  89. private IInfoUserService infoUserService;
  90. @Autowired
  91. private PushEventService pushEventService;
  92. @Autowired
  93. private OrderLogHelper orderLogHelper;
  94. @Autowired
  95. private IPosOrderOmgRefundService refundService;
  96. @Autowired
  97. private OrderLifecycleService orderLifecycleService;
  98. @Autowired
  99. private PaymentCreateGuardService paymentCreateGuardService;
  100. @Autowired
  101. private OmgQueryThrottle omgQueryThrottle;
  102. @Value("${omg.base-url}")
  103. private String baseUrl;
  104. @Value("${omg.return-url}")
  105. private String returnUrl;
  106. @Value("${omg.order-result-url}")
  107. private String orderResultUrl;
  108. @Value("${omg.payment-info-url}")
  109. private String paymentInfoUrl;
  110. /** create 复用新鲜期(分钟):窗口内且 trade_no 为空的活跃行复用 MTN,超期则轮换新建(T058 防堆积,stage 实测调整)。 */
  111. @Value("${omg.create.reuse-fresh-minutes:3}")
  112. private int reuseFreshMinutes;
  113. // ============================ US1:发起 AIO 幕前支付 ============================
  114. /**
  115. * 发起 OMG AIO 幕前支付。校验订单归属/未支付/金额 → 查门店启用凭证 → 生成 MerchantTradeNo →
  116. * 组参 + CheckMacValue → 落流水(pay_status=0) + 更新订单 payType=PAY_TYPE_OMG/payUrl → 返回 form 字段供前端 Form Post。
  117. */
  118. @Anonymous
  119. @Auth
  120. @RepeatSubmit(interval = 1000, message = "请求过于频繁")
  121. @PostMapping("/create")
  122. @Transactional(rollbackFor = Exception.class)
  123. public AjaxResult create(@RequestHeader String token,
  124. @RequestBody(required = false) OmgOrderRequest request) {
  125. if (invalidOrderRequest(request)) {
  126. return error(MessageUtils.message("no.order.id.error"));
  127. }
  128. // 按 ddId 串行化发起(T058 防堆积):同一订单并发 create 在分布式锁内排队,锁释放在事务提交后,
  129. // 保证后一个请求能看到前一个已提交的活跃流水 → 命中复用而非新建(详见 PaymentCreateGuardService)。
  130. return paymentCreateGuardService == null ? createUnderLock(token, request)
  131. : paymentCreateGuardService.withLock(request.getOrderid(),
  132. () -> createUnderLock(token, request));
  133. }
  134. /**
  135. * 发起支付的实际业务逻辑,在 {@link #create} 的 {@code @Transactional} 与 {@link PaymentCreateGuardService}
  136. * 分布式锁内执行。锁释放在事务 afterCommit,确保并发请求看不到未提交的活跃流水 INSERT,避免重复活跃行。
  137. *
  138. * <p>流程:校验登录/订单归属/未支付/金额/门店 → 查门店 OMG 凭证 → 复用或新建 MerchantTradeNo →
  139. * 组 AIO 参 + CheckMacValue → 落流水(pay_status=0) + 更新订单 payType/payUrl → 返回 form 字段。
  140. */
  141. private AjaxResult createUnderLock(String token, OmgOrderRequest request) {
  142. if (invalidOrderRequest(request)) {
  143. return error(MessageUtils.message("no.order.id.error"));
  144. }
  145. String orderid = request.getOrderid();
  146. String userId;
  147. try {
  148. userId = new JwtUtil().getusid(token);
  149. } catch (Exception e) {
  150. return error(MessageUtils.message("no.order.id.error"));
  151. }
  152. if (userId == null || userId.isEmpty()) {
  153. return error("请先登录");
  154. }
  155. PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
  156. if (order == null) {
  157. return error(MessageUtils.message("no.order.id.error"));
  158. }
  159. if (order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
  160. return error("无权操作该订单");
  161. }
  162. if (!PAY_TYPE_OMG.equals(order.getPayType())) {
  163. return error("订单支付方式不是 OMG");
  164. }
  165. if (order.getState() != null && order.getState() == 4L) {
  166. return error("订单已取消,不可重新支付");
  167. }
  168. if (order.getPayStatus() != null && order.getPayStatus() == 1L) {
  169. return error("订单已支付");
  170. }
  171. if (order.getAmount() == null || order.getAmount() <= 0) {
  172. return error("订单金额异常");
  173. }
  174. if (order.getMdId() == null) {
  175. return error("订单门店缺失");
  176. }
  177. // 门店 OMG 凭证(已开通且启用)→ 转 OmgPayConfig(Controller 层完成,避免 system→admin 反向依赖)
  178. PosStoreOmg cred = storeOmgService.getEnabledCredential(order.getMdId());
  179. if (cred == null) {
  180. return error("该门店暂不支持线上支付");
  181. }
  182. OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
  183. String merchantTradeNo = resolveMerchantTradeNo(order, cred);
  184. // OMG AIO 参数(contracts/api.md §A1/B1);InvoiceMark=N 固定(发票走 ezPay);EncryptType=1 固定(SHA256)
  185. SimpleDateFormat fmt = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
  186. fmt.setTimeZone(TimeZone.getTimeZone("Asia/Taipei"));
  187. Map<String, String> params = new LinkedHashMap<>();
  188. params.put("MerchantID", cred.getMerchantId());
  189. params.put("MerchantTradeNo", merchantTradeNo);
  190. params.put("MerchantTradeDate", fmt.format(new Date()));
  191. params.put("PaymentType", "aio");
  192. params.put("TotalAmount", String.valueOf(order.getAmount()));
  193. params.put("TradeDesc", "food order " + orderid);
  194. params.put("ItemName", "order " + orderid);
  195. params.put("ReturnURL", returnUrl);
  196. params.put("ChoosePayment", "ALL");
  197. params.put("EncryptType", "1");
  198. params.put("InvoiceMark", "N");
  199. params.put("NeedExtraPaidInfo", "Y");
  200. if (paymentInfoUrl != null && !paymentInfoUrl.isEmpty()) {
  201. params.put("PaymentInfoURL", paymentInfoUrl);
  202. }
  203. if (orderResultUrl != null && !orderResultUrl.isEmpty()) {
  204. params.put("OrderResultURL", orderResultUrl);
  205. }
  206. // 组参 + CheckMacValue,返回含 gatewayUrl 的 form 字段
  207. Map<String, String> form = omgPay.createAioForm(baseUrl, cfg, params);
  208. // 更新订单 payType=OMG(PAY_TYPE_OMG) / payUrl=gatewayUrl(仅更这两个字段)
  209. PosOrder upd = new PosOrder();
  210. upd.setId(order.getId());
  211. upd.setPayType(PAY_TYPE_OMG);
  212. upd.setPayUrl(form.get("gatewayUrl"));
  213. posOrderService.saveOrUpdate(upd);
  214. log.info("[OMG] create orderid={}, merchantTradeNo={}, amount={}, gatewayUrl={}",
  215. orderid, merchantTradeNo, order.getAmount(), form.get("gatewayUrl"));
  216. return success(form);
  217. }
  218. /**
  219. * 解析本次发起用的 MerchantTradeNo(T058 防堆积核心)。
  220. *
  221. * <p>优先复用新鲜期内活跃未付且未取号(trade_no IS NULL)的旧 MTN(连点/重发起命中 → 不新建流水);
  222. * 未命中则先把旧行轮换为历史(is_active 1→0,pay_status 不动,保留接迟到 notify 与审计),再新建活跃行。
  223. * 必须在 create 的 @Transactional 与 PaymentCreateGuardService 分布式锁内调用。
  224. */
  225. private String resolveMerchantTradeNo(PosOrder order, PosStoreOmg cred) {
  226. String ddId = String.valueOf(order.getDdId());
  227. PosOrderOmgPayment reusable = paymentService.getActiveForReuse(ddId, reuseFreshMinutes);
  228. if (reusable != null) {
  229. log.info("[OMG] create reuse orderid={}, merchantTradeNo={} (fresh within {}min, trade_no null)",
  230. order.getDdId(), reusable.getMerchantTradeNo(), reuseFreshMinutes);
  231. return reusable.getMerchantTradeNo();
  232. }
  233. paymentService.markActiveHistorical(ddId);
  234. return createPaymentAttempt(order, cred);
  235. }
  236. /**
  237. * MerchantTradeNo 生成:OMG 要求全平台永久唯一、≤20 字元、英数大小写混合,且不可复用。
  238. * 使用 "OMG" + UUID 随机片段,并依靠数据库唯一索引与冲突重试兜底;ddId 由支付流水反查。
  239. */
  240. private String createPaymentAttempt(PosOrder order, PosStoreOmg cred) {
  241. for (int attempt = 0; attempt < 3; attempt++) {
  242. String merchantTradeNo = genMerchantTradeNo();
  243. try {
  244. paymentService.createPayment(String.valueOf(order.getDdId()), merchantTradeNo, order.getMdId(),
  245. cred.getMerchantId(), order.getAmount(), "ALL");
  246. return merchantTradeNo;
  247. } catch (DuplicateKeyException e) {
  248. if (attempt == 2) {
  249. throw e;
  250. }
  251. }
  252. }
  253. throw new IllegalStateException("Unable to allocate OMG MerchantTradeNo");
  254. }
  255. private String genMerchantTradeNo() {
  256. String random = UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
  257. return "OMG" + random.substring(0, 17);
  258. }
  259. // ============================ US2:ReturnURL 支付结果回调 ============================
  260. /**
  261. * OMG 支付结果回调(@Anonymous,OMG 服务端 Form Post)。OMG 明文参数 + 单 CheckMacValue(无 AES 解密)。
  262. *
  263. * <p>记 IPN → 按 MerchantID 查凭证 → 验签 → trade_no 幂等 → 金额校验 → RtnCode==1 且 SimulatePaid!=1
  264. * → markSuccess + 推送 → 回纯串 {@code 1|OK}(注意:非 JSON,与蓝新不同)。任何异常/校验失败都仍回 1|OK
  265. * (OMG 收到非成功会重试),但绝不错误更新订单。
  266. */
  267. @Anonymous
  268. @PostMapping(value = "/notify", produces = "text/plain;charset=UTF-8")
  269. @Transactional(rollbackFor = Exception.class)
  270. public String notify(@ModelAttribute OmgCallbackRequest callback,
  271. @RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor) {
  272. Map<String, String> form;
  273. try {
  274. form = callback.toParameterMap();
  275. } catch (IllegalArgumentException e) {
  276. log.warn("OMG callback rejected: {}", e.getMessage());
  277. return "1|OK";
  278. }
  279. // 记录 IPN 日志
  280. try {
  281. IpnLog ipnLog = new IpnLog();
  282. ipnLog.setIp(callbackIp(forwardedFor));
  283. ipnLog.setIpnLog(auditJson(form));
  284. ipnLog.setType("omg");
  285. ipnLogService.insertIpnLog(ipnLog);
  286. } catch (Exception e) {
  287. log.warn("记 OMG IPN 日志失败", e);
  288. }
  289. String merchantId = form.get("MerchantID");
  290. if (merchantId == null || merchantId.isEmpty()) {
  291. log.warn("OMG callback missing MerchantID");
  292. return "1|OK";
  293. }
  294. PosStoreOmg cred = storeOmgService.getCredentialByMerchantId(merchantId);
  295. if (cred == null) {
  296. log.warn("OMG 回调无匹配凭证: merchantId={}", merchantId);
  297. return "1|OK";
  298. }
  299. // 验签(OMG 无解密,直接对全部回调参 + CheckMacValue 重算比对)
  300. if (!OmgCheckMacValue.verify(form, cred.getHashKey(), cred.getHashIv())) {
  301. log.warn("OMG 回调验签失败: merchantId={}", merchantId);
  302. return "1|OK";
  303. }
  304. String tradeNo = form.get("TradeNo");
  305. String merchantTradeNo = form.get("MerchantTradeNo");
  306. if (tradeNo == null || tradeNo.isEmpty() || merchantTradeNo == null || merchantTradeNo.isEmpty()) {
  307. log.warn("OMG callback missing trade identifiers: merchantId={}", merchantId);
  308. return "1|OK";
  309. }
  310. int rtnCode = toInt(form.get("RtnCode"), -1);
  311. int tradeAmt = toInt(form.get("TradeAmt"), -1);
  312. String simulatePaid = form.get("SimulatePaid");
  313. String paymentType = form.get("PaymentType");
  314. String paymentDate = form.get("PaymentDate");
  315. // 订单关联:由 MerchantTradeNo 反查流水与订单
  316. PosOrderOmgPayment payment = paymentService.getByMerchantTradeNo(merchantTradeNo);
  317. if (payment == null) {
  318. log.warn("OMG 回调无对应发起记录: merchantTradeNo={}", merchantTradeNo);
  319. return "1|OK";
  320. }
  321. if (!merchantId.equals(payment.getMerchantId()) || payment.getStoreId() == null
  322. || cred.getStoreId() == null || !payment.getStoreId().equals(cred.getStoreId())) {
  323. log.warn("OMG callback credential/ledger mismatch: merchantId={}, merchantTradeNo={}", merchantId, merchantTradeNo);
  324. return "1|OK";
  325. }
  326. PosOrderOmgPayment exist = paymentService.getByTradeNo(tradeNo);
  327. if (exist != null && !payment.getId().equals(exist.getId())) {
  328. log.warn("OMG TradeNo already belongs to another payment: tradeNo={}", tradeNo);
  329. return "1|OK";
  330. }
  331. String ddId = payment.getDdId();
  332. PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", ddId));
  333. if (order == null) {
  334. log.warn("OMG 回调订单不存在: ddId={}", ddId);
  335. return "1|OK";
  336. }
  337. if (order.getMdId() == null || !order.getMdId().equals(payment.getStoreId())
  338. || !order.getMdId().equals(cred.getStoreId())) {
  339. log.warn("OMG callback order/store mismatch: ddId={}", ddId);
  340. return "1|OK";
  341. }
  342. // 金额校验(以平台实际应收金额为准)
  343. if (payment.getAmount() == null || order.getAmount() == null
  344. || tradeAmt != payment.getAmount() || order.getAmount().intValue() != payment.getAmount()) {
  345. log.error("OMG callback amount mismatch: ddId={}, paymentAmt={}, orderAmt={}, callbackAmt={}",
  346. ddId, payment.getAmount(), order.getAmount(), tradeAmt);
  347. return "1|OK";
  348. }
  349. boolean terminalOrder = (order.getState() != null && order.getState() == 4L)
  350. || (order.getPayStatus() != null && order.getPayStatus() == 2L);
  351. // 流水已成功但订单未核销时,重复回调用于补偿上一次订单更新失败。
  352. if (Integer.valueOf(1).equals(payment.getPayStatus())) {
  353. if (!tradeNo.equals(payment.getTradeNo())) {
  354. log.warn("OMG paid callback TradeNo mismatch: merchantTradeNo={}", merchantTradeNo);
  355. return "1|OK";
  356. }
  357. if (payment.getPayType() != null && paymentType != null && !paymentType.equals(payment.getPayType())) {
  358. log.warn("OMG paid callback PaymentType mismatch: merchantTradeNo={}", merchantTradeNo);
  359. return "1|OK";
  360. }
  361. if (rtnCode == 1 && !"1".equals(simulatePaid)
  362. && !Long.valueOf(1L).equals(order.getPayStatus())) {
  363. recordPaidOrderWithoutFulfillment(order, terminalOrder
  364. ? "OMG支付成功回调晚于订单取消/退款,需立即退款或人工核对"
  365. : "系统补偿OMG已支付流水与订单支付状态不一致");
  366. }
  367. return "1|OK";
  368. }
  369. if (rtnCode == 1 && !"1".equals(simulatePaid)) {
  370. Date payTime = parsePayTime(paymentDate);
  371. Date tradeDate = parsePayTime(form.get("TradeDate"));
  372. if (payTime == null || tradeDate == null) {
  373. log.warn("OMG callback contains invalid date: merchantTradeNo={}", merchantTradeNo);
  374. return "1|OK";
  375. }
  376. String authCode = form.get("auth_code");
  377. if (authCode == null) {
  378. authCode = form.get("AuthCode");
  379. }
  380. // 核销成功(notify/补单共用:幂等 markSuccess + 订单状态流转 + 推送)
  381. applyPaidResult(payment, order, tradeNo, paymentType, rtnCode, form.get("RtnMsg"),
  382. authCode, payTime, tradeDate, auditJson(form));
  383. } else if (rtnCode != 1) {
  384. paymentService.markFail(payment.getId(), rtnCode, form.get("RtnMsg"), auditJson(form));
  385. log.warn("OMG 回调交易失败: ddId={}, rtnCode={}, rtnMsg={}", ddId, rtnCode, form.get("RtnMsg"));
  386. } else {
  387. // SimulatePaid=1 模拟支付:不发货,仅记录
  388. log.warn("OMG 回调为模拟支付(SimulatePaid=1),不发货: ddId={}", ddId);
  389. }
  390. return "1|OK";
  391. }
  392. /**
  393. * 支付完成返回页(@Anonymous)。仅 302 引导回前端结果页(带 ddId),<b>不</b>改订单状态(以 notify 为准)。
  394. */
  395. @Anonymous
  396. @RequestMapping(value = "/return", method = {RequestMethod.GET, RequestMethod.POST})
  397. public void returnCallback(@ModelAttribute OmgCallbackRequest callback,
  398. HttpServletResponse response) throws IOException {
  399. String ddId = "";
  400. try {
  401. Map<String, String> form = callback.toParameterMap();
  402. String mtn = form.get("MerchantTradeNo");
  403. // 由 MerchantTradeNo 反查流水拿 ddId(MTN 不再编码 ddId)
  404. if (mtn != null && !mtn.isEmpty()) {
  405. PosOrderOmgPayment p = paymentService.getByMerchantTradeNo(mtn);
  406. if (p != null && p.getDdId() != null) {
  407. ddId = p.getDdId();
  408. }
  409. }
  410. } catch (Exception e) {
  411. log.warn("OMG ReturnURL 解析失败", e);
  412. }
  413. if (orderResultUrl == null || orderResultUrl.isEmpty()) {
  414. log.warn("OMG ReturnURL 未配置 omg.order-result-url,无法重定向");
  415. response.setStatus(204);
  416. return;
  417. }
  418. String sep = orderResultUrl.contains("?") ? "&" : "?";
  419. response.sendRedirect(orderResultUrl + sep + "ddId=" + URLEncoder.encode(ddId == null ? "" : ddId, StandardCharsets.UTF_8));
  420. }
  421. // ============================ US3:ATM/超商 取号回调 + 取号查询 ============================
  422. /**
  423. * OMG ATM/超商取号回调(@Anonymous,PaymentInfoURL)。验签后把虚帐/缴费码原始报文
  424. * (BankCode/vAccount/ExpireDate 或 PaymentNo/ExpireDate 等)落到流水的 callbackRaw,不改 pay_status;
  425. * 实际付款后 OMG 再回调 /pay/omg/notify(RtnCode=1)走 US2 核销。回纯串 {@code 1|OK}。
  426. */
  427. @Anonymous
  428. @PostMapping(value = "/paymentInfo", produces = "text/plain;charset=UTF-8")
  429. public String paymentInfoCallback(@ModelAttribute OmgCallbackRequest callback,
  430. @RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor) {
  431. Map<String, String> form;
  432. try {
  433. form = callback.toParameterMap();
  434. } catch (IllegalArgumentException e) {
  435. log.warn("OMG paymentInfo rejected: {}", e.getMessage());
  436. return "1|OK";
  437. }
  438. try {
  439. IpnLog ipnLog = new IpnLog();
  440. ipnLog.setIp(callbackIp(forwardedFor));
  441. ipnLog.setIpnLog(auditJson(form));
  442. ipnLog.setType("omg");
  443. ipnLogService.insertIpnLog(ipnLog);
  444. } catch (Exception e) {
  445. log.warn("记 OMG paymentInfo IPN 日志失败", e);
  446. }
  447. String merchantId = form.get("MerchantID");
  448. if (merchantId == null || merchantId.isEmpty()) {
  449. log.warn("OMG paymentInfo missing MerchantID");
  450. return "1|OK";
  451. }
  452. PosStoreOmg cred = storeOmgService.getCredentialByMerchantId(merchantId);
  453. if (cred == null) {
  454. log.warn("OMG paymentInfo 无匹配凭证: merchantId={}", merchantId);
  455. return "1|OK";
  456. }
  457. if (!OmgCheckMacValue.verify(form, cred.getHashKey(), cred.getHashIv())) {
  458. log.warn("OMG paymentInfo 验签失败: merchantId={}", merchantId);
  459. return "1|OK";
  460. }
  461. String merchantTradeNo = form.get("MerchantTradeNo");
  462. PosOrderOmgPayment payment = paymentService.getByMerchantTradeNo(merchantTradeNo);
  463. if (payment == null) {
  464. log.warn("OMG paymentInfo 无对应发起记录: merchantTradeNo={}", merchantTradeNo);
  465. return "1|OK";
  466. }
  467. if (!merchantId.equals(payment.getMerchantId()) || payment.getStoreId() == null
  468. || cred.getStoreId() == null || !payment.getStoreId().equals(cred.getStoreId())) {
  469. log.warn("OMG paymentInfo credential/ledger mismatch: merchantTradeNo={}", merchantTradeNo);
  470. return "1|OK";
  471. }
  472. // 取号成功 RtnCode:ATM=2,CVS/BarcodeATM=10100073(取号本身非付款;付款成功另走 /notify 的 RtnCode=1)
  473. int pickupRtnCode = toInt(form.get("RtnCode"), -1);
  474. if (pickupRtnCode != 2 && pickupRtnCode != 10100073) {
  475. log.warn("OMG paymentInfo 取号非成功 rtnCode={}, merchantTradeNo={}", pickupRtnCode, merchantTradeNo);
  476. return "1|OK";
  477. }
  478. // 落取号信息(BankCode/vAccount/ExpireDate 或 PaymentNo/ExpireDate)为 JSON,不改 pay_status
  479. paymentService.markPaymentInfo(payment.getId(), form.get("TradeNo"), auditJson(form));
  480. log.info("[OMG] paymentInfo 已记录取号信息: ddId={}, merchantTradeNo={}", payment.getDdId(), merchantTradeNo);
  481. return "1|OK";
  482. }
  483. /**
  484. * 取号信息查询(前端 ATM/超商结果页展示虚帐/缴费码 + 期限用,@Auth)。
  485. * 返回 payType/amount/payStatus + 解析后的取号字段 info。
  486. */
  487. @Anonymous
  488. @Auth
  489. @GetMapping("/paymentInfo/{orderid}")
  490. public AjaxResult getPaymentInfo(@RequestHeader String token, @PathVariable String orderid) {
  491. String userId;
  492. try {
  493. userId = new JwtUtil().getusid(token);
  494. } catch (Exception e) {
  495. return error(MessageUtils.message("no.order.id.error"));
  496. }
  497. if (userId == null || userId.isEmpty()) {
  498. return error("请先登录");
  499. }
  500. PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
  501. if (order == null || order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
  502. return error("无权操作该订单");
  503. }
  504. // 优先取已付/退款行(展示回执);无则取最新行(未付款场景展示 ATM/超商虚帐)
  505. PosOrderOmgPayment p = paymentService.getLatestRefundableByDdId(orderid);
  506. if (p == null) {
  507. p = paymentService.getLatestByDdId(orderid);
  508. }
  509. if (p == null) {
  510. return error("无支付记录");
  511. }
  512. Map<String, Object> result = new LinkedHashMap<>();
  513. result.put("payType", p.getPayType());
  514. result.put("amount", p.getAmount());
  515. result.put("payStatus", p.getPayStatus());
  516. result.put("info", parsePaymentInfo(p.getCallbackRaw()));
  517. return success(result);
  518. }
  519. // ============================ US4:订单取消与退款 ============================
  520. /**
  521. * OMG 退款(@Auth,供取消链路/管理员调用)。信用卡(含 Apple Pay) 调 DoAction(Action=R 退刷);
  522. * ATM/超商/BarcodeATM 无退款 API → 记录待人工在 OMG 后台处理。仅正式端点可用(stage DoAction 不可用)。
  523. */
  524. @Anonymous
  525. @Auth
  526. @PostMapping("/refund")
  527. public AjaxResult refund(@RequestHeader String token,
  528. @RequestBody(required = false) OmgOrderRequest request) {
  529. if (invalidOrderRequest(request)) {
  530. return error(MessageUtils.message("no.order.id.error"));
  531. }
  532. String orderid = request.getOrderid();
  533. String userId;
  534. try {
  535. userId = new JwtUtil().getusid(token);
  536. } catch (Exception e) {
  537. return error(MessageUtils.message("no.order.id.error"));
  538. }
  539. if (userId == null || userId.isEmpty()) {
  540. return error("请先登录");
  541. }
  542. PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
  543. if (order == null) {
  544. return error("订单不存在");
  545. }
  546. if (order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
  547. return error("无权操作该订单");
  548. }
  549. return refundOrder(order);
  550. }
  551. /**
  552. * OMG 退款核心(public,供取消链路 {@code cancelOrder} 直接调用,免重复实现)。
  553. * 信用卡(含 Apple Pay) 调 DoAction(Action=R 退刷);ATM/超商/BarcodeATM 记人工。仅正式端点可用。
  554. * 返回 success=已退款;error=需人工或失败(refund 已落记录可重试)。
  555. */
  556. public AjaxResult refundOrder(PosOrder order) {
  557. OmgRefundOutcome outcome = refundOrderOutcome(order);
  558. if (outcome.getStatus() == OmgRefundOutcome.Status.REFUNDED
  559. || outcome.getStatus() == OmgRefundOutcome.Status.IDEMPOTENT) {
  560. try {
  561. orderLifecycleService.finalizeSystemOmgRefund(order.getId());
  562. return success(outcome.getMessage());
  563. } catch (Exception e) {
  564. log.error("OMG refund succeeded but order state synchronization failed: orderId={}, errorType={}",
  565. order == null ? null : order.getId(), e.getClass().getSimpleName());
  566. return error("OMG 退款成功,本地订单状态待同步");
  567. }
  568. }
  569. return error(outcome.getMessage());
  570. }
  571. /**
  572. * 返回明确退款结果,供管理端根据资金事实同步订单状态。
  573. */
  574. public OmgRefundOutcome refundOrderOutcome(PosOrder order) {
  575. if (order == null) {
  576. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "订单不存在");
  577. }
  578. if (!PAY_TYPE_OMG.equals(order.getPayType())) {
  579. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "该订单非 OMG 支付");
  580. }
  581. if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
  582. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "订单未支付,无需退款");
  583. }
  584. String ddId = String.valueOf(order.getDdId());
  585. PosOrderOmgPayment payment = paymentService.getLatestRefundableByDdId(ddId);
  586. if (payment == null) {
  587. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无 OMG 支付流水");
  588. }
  589. if (Integer.valueOf(3).equals(payment.getPayStatus())) {
  590. return OmgRefundOutcome.of(OmgRefundOutcome.Status.IDEMPOTENT, "OMG 已完成退款");
  591. }
  592. if (Integer.valueOf(4).equals(payment.getPayStatus())) {
  593. return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN,
  594. "OMG 退款结果待确认,请勿重复发起");
  595. }
  596. if (!Integer.valueOf(1).equals(payment.getPayStatus())) {
  597. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 支付流水不是已支付状态");
  598. }
  599. int amount = payment.getAmount() == null ? 0 : payment.getAmount();
  600. String payType = payment.getPayType();
  601. if (amount <= 0 || payment.getTradeNo() == null || payment.getTradeNo().isEmpty()) {
  602. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 支付流水不完整");
  603. }
  604. if (order.getAmount() == null || order.getAmount().intValue() != amount) {
  605. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 支付流水金额与订单不一致");
  606. }
  607. // ATM/超商/BarcodeATM 无退款 API → 记录待人工
  608. if (!"Credit_CreditCard".equals(payType)) {
  609. List<com.ruoyi.system.domain.PosOrderOmgRefund> records = refundService.listByPayment(payment.getId());
  610. boolean pending = records != null && records.stream()
  611. .anyMatch(row -> row.getAction() == null && row.getRtnCode() == null);
  612. boolean completed = records != null && records.stream()
  613. .anyMatch(row -> row.getAction() == null && Integer.valueOf(1).equals(row.getRtnCode()));
  614. if (completed) {
  615. return OmgRefundOutcome.of(OmgRefundOutcome.Status.IDEMPOTENT, "OMG 人工退款已确认完成");
  616. }
  617. if (!pending) {
  618. refundService.record(payment.getId(), ddId, payment.getTradeNo(), null, amount, null,
  619. "延期支付方式无退款API,待人工在 OMG 后台处理", "");
  620. }
  621. return OmgRefundOutcome.of(OmgRefundOutcome.Status.MANUAL_PENDING,
  622. "该支付方式需在 OMG 后台人工退款,订单暂保持已支付");
  623. }
  624. if (paymentService.markRefunding(payment.getId()) == 0) {
  625. PosOrderOmgPayment latest = paymentService.getLatestRefundableByDdId(ddId);
  626. if (latest != null && Integer.valueOf(3).equals(latest.getPayStatus())) {
  627. return OmgRefundOutcome.of(OmgRefundOutcome.Status.IDEMPOTENT, "OMG 已完成退款");
  628. }
  629. return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "退款已处理或正在处理中");
  630. }
  631. PosStoreOmg cred = storeOmgService.getCredentialByMerchantId(payment.getMerchantId());
  632. if (cred == null || cred.getStoreId() == null || !cred.getStoreId().equals(payment.getStoreId())) {
  633. paymentService.restorePaidFromRefunding(payment.getId());
  634. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "门店 OMG 凭证不可用");
  635. }
  636. OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
  637. // 信用卡(含 Apple Pay)→ DoAction(Action=R 退刷);MVP 统一 R,失败再按状态分支(D7)
  638. refundService.record(payment.getId(), ddId, payment.getTradeNo(), "R", amount, null,
  639. "退款请求处理中", "");
  640. Map<String, String> resp;
  641. try {
  642. resp = omgPay.doAction(baseUrl, cfg, payment.getMerchantTradeNo(), payment.getTradeNo(), "R", amount);
  643. } catch (Exception e) {
  644. refundService.record(payment.getId(), ddId, payment.getTradeNo(), "R", amount, null,
  645. "退款结果未知,需对账", "");
  646. log.error("OMG refund outcome unknown: ddId={}, paymentId={}, errorType={}",
  647. ddId, payment.getId(), e.getClass().getSimpleName());
  648. return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN,
  649. "OMG 退款结果待确认,请勿重复发起");
  650. }
  651. int rtnCode = toInt(resp == null ? null : resp.get("RtnCode"), -1);
  652. String rtnMsg = resp == null ? "" : resp.get("RtnMsg");
  653. refundService.record(payment.getId(), ddId, payment.getTradeNo(), "R", amount, rtnCode, rtnMsg,
  654. resp == null ? "" : JSON.toJSONString(resp));
  655. if (rtnCode == 1) {
  656. if (paymentService.markRefunded(payment.getId()) == 0) {
  657. log.error("OMG refund succeeded but ledger transition failed: paymentId={}", payment.getId());
  658. return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 退款成功,账务状态待核对");
  659. }
  660. orderLogHelper.logSync(ddId, 0, null, "系统", "OMG 信用卡退款成功");
  661. return OmgRefundOutcome.of(OmgRefundOutcome.Status.REFUNDED, "退款成功");
  662. }
  663. paymentService.restorePaidFromRefunding(payment.getId());
  664. log.warn("OMG refund failed: ddId={}, paymentId={}, rtnCode={}", ddId, payment.getId(), rtnCode);
  665. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 退款失败,请核对后重试");
  666. }
  667. /**
  668. * 管理员已在 OMG 后台核实完成延期支付退款后,收口支付流水。
  669. */
  670. public OmgRefundOutcome confirmManualRefundOutcome(PosOrder order) {
  671. if (order == null || !PAY_TYPE_OMG.equals(order.getPayType())) {
  672. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "该订单非 OMG 支付");
  673. }
  674. String ddId = String.valueOf(order.getDdId());
  675. PosOrderOmgPayment payment = paymentService.getLatestRefundableByDdId(ddId);
  676. if (payment == null || "Credit_CreditCard".equals(payment.getPayType())) {
  677. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无可确认的 OMG 人工退款待办");
  678. }
  679. List<com.ruoyi.system.domain.PosOrderOmgRefund> records = refundService.listByPayment(payment.getId());
  680. boolean pending = records != null && records.stream()
  681. .anyMatch(row -> row.getAction() == null && row.getRtnCode() == null);
  682. boolean completed = records != null && records.stream()
  683. .anyMatch(row -> row.getAction() == null && Integer.valueOf(1).equals(row.getRtnCode()));
  684. if (!pending && !completed) {
  685. return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无可确认的 OMG 人工退款待办");
  686. }
  687. if (Integer.valueOf(4).equals(payment.getPayStatus())) {
  688. return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 退款状态正在处理中");
  689. }
  690. if (!Integer.valueOf(3).equals(payment.getPayStatus())) {
  691. if (!Integer.valueOf(1).equals(payment.getPayStatus())
  692. || paymentService.markRefunding(payment.getId()) == 0
  693. || paymentService.markRefunded(payment.getId()) == 0) {
  694. return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 人工退款状态发生变化,请刷新");
  695. }
  696. }
  697. if (!completed) {
  698. refundService.record(payment.getId(), ddId, payment.getTradeNo(), null, payment.getAmount(), 1,
  699. "管理员确认已在 OMG 后台完成人工退款", "");
  700. }
  701. return OmgRefundOutcome.of(OmgRefundOutcome.Status.REFUNDED, "已确认 OMG 人工退款完成");
  702. }
  703. // ============================ US6:漏单补单(回调可靠性 / callback-reconcile.md) ============================
  704. /**
  705. * 方案A 被动补单(@Auth)。前端结果页轮询仍 {@code payStatus=0} 时调用:后端查 OMG 真实状态并补单。
  706. * 返回 {@code {payStatus:0未付/1已付/2失败, reconciled:true=本次触发补单}}。严格幂等(见 reconcileByQuery)。
  707. */
  708. @Anonymous
  709. @Auth
  710. @RepeatSubmit(interval = 2000, message = "查询过于频繁")
  711. @PostMapping("/query")
  712. public AjaxResult query(@RequestHeader String token,
  713. @RequestBody(required = false) OmgOrderRequest request) {
  714. if (invalidOrderRequest(request)) {
  715. return error(MessageUtils.message("no.order.id.error"));
  716. }
  717. String orderid = request.getOrderid();
  718. String userId;
  719. try {
  720. userId = new JwtUtil().getusid(token);
  721. } catch (Exception e) {
  722. return error(MessageUtils.message("no.order.id.error"));
  723. }
  724. if (userId == null || userId.isEmpty()) {
  725. return error("请先登录");
  726. }
  727. PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
  728. if (order == null) {
  729. return error(MessageUtils.message("no.order.id.error"));
  730. }
  731. if (order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
  732. return error("无权操作该订单");
  733. }
  734. if (!PAY_TYPE_OMG.equals(order.getPayType())) {
  735. return error("该订单非 OMG 支付");
  736. }
  737. if (order.getState() != null && order.getState() == 4L) {
  738. return error("订单已取消");
  739. }
  740. Map<String, Object> result = new LinkedHashMap<>();
  741. Long payStatus = order.getPayStatus();
  742. // 订单已核销/已退款 → 直接返回,不重复查 OMG
  743. if (payStatus != null && (payStatus == 1L || payStatus == 2L)) {
  744. result.put("payStatus", payStatus == 1L ? 1 : 2);
  745. result.put("reconciled", false);
  746. return success(result);
  747. }
  748. int[] res = reconcileByQuery(orderid, "query");
  749. result.put("payStatus", res[0]);
  750. result.put("reconciled", res[1] == 1);
  751. return success(result);
  752. }
  753. /**
  754. * 查询 OMG 真实交易状态并按结果补单/标失败。{@code /query}(被动补单/方案A) 与定时任务(方案B)共用。
  755. *
  756. * <p>幂等与自愈:流水已 {@code pay_status=1} 时,若订单未核销(跨事务中断残留)则补推订单状态,
  757. * 否则直接返回不重复处理;流水处于失败/退款终态不再查询。
  758. *
  759. * @param ddId 订单号
  760. * @param source 调用来源标记("query" 被动补单 / "scheduled" 定时补单),仅用于日志
  761. * @return {@code int[2]} = {payStatus, reconciled}:payStatus 0未付/1已付/2失败;reconciled 1=本次触发补单核销
  762. */
  763. public int[] reconcileByQuery(String ddId, String source) {
  764. PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", ddId));
  765. if (order == null) {
  766. return new int[]{0, 0};
  767. }
  768. if (order.getState() != null && order.getState() == 4L) {
  769. return new int[]{0, 0};
  770. }
  771. // ① 自愈:流水已付(pay_status IN(1,3,4))但订单未核销(跨事务中断残留)→ 补推订单状态
  772. PosOrderOmgPayment paid = paymentService.getLatestPaidByDdId(ddId);
  773. if (paid != null) {
  774. boolean isPaid = Integer.valueOf(1).equals(paid.getPayStatus());
  775. if (isPaid && (order.getPayStatus() == null || order.getPayStatus() == 0L)) {
  776. handlePaymentSuccess(order);
  777. }
  778. return new int[]{isPaid ? 1 : 0, 0};
  779. }
  780. // 闸3:per-ddId 扫描间隔节流(/query 60s / 定时 180s),挡前端轮询放大、防 OMG 403 自残(T055)
  781. if (!omgQueryThrottle.acquireDdIdSlot(ddId, source)) {
  782. log.debug("[OMG-{}] per-ddId 节流,跳过本轮 OMG 查询: ddId={}", source, ddId);
  783. return new int[]{order.getPayStatus() == null ? 0 : order.getPayStatus().intValue(), 0};
  784. }
  785. // ② 遍历全量未付(pay_status=0,含历史行)queryTrade,任一已付即补单核销(补较早 MTN 如 id=26)
  786. List<PosOrderOmgPayment> unpaid = paymentService.listUnpaidByDdId(ddId);
  787. if (unpaid == null || unpaid.isEmpty()) {
  788. return new int[]{0, 0};
  789. }
  790. int lastStatus = 0;
  791. for (PosOrderOmgPayment p : unpaid) {
  792. PosStoreOmg cred = storeOmgService.getCredentialByMerchantIdAndStoreId(p.getMerchantId(), p.getStoreId());
  793. if (cred == null) {
  794. log.error("[OMG-{}] 门店凭证不可用: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo());
  795. continue;
  796. }
  797. // per-MerchantID 令牌桶节流(1token/3s,burst1):拿不到令牌跳过本行本轮,不阻塞(防 OMG 403)
  798. if (!omgQueryThrottle.tryAcquireMerchantToken(p.getMerchantId())) {
  799. log.debug("[OMG-{}] MerchantID 令牌桶限流,跳过本行本轮: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo());
  800. continue;
  801. }
  802. OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
  803. Map<String, String> resp;
  804. try {
  805. resp = omgPay.queryTrade(baseUrl, cfg, p.getMerchantTradeNo());
  806. } catch (Exception e) {
  807. log.error("[OMG-{}] queryTrade 失败(下轮重试): ddId={}, mtcn={}, err={}: {}",
  808. source, ddId, p.getMerchantTradeNo(), e.getClass().getName(), e.getMessage());
  809. Map<String, String> errLog = new LinkedHashMap<>();
  810. errLog.put("ddId", ddId);
  811. errLog.put("mtcn", p.getMerchantTradeNo());
  812. errLog.put("source", source);
  813. errLog.put("error", e.getClass().getName() + ": " + e.getMessage());
  814. IpnLog ipnLog = new IpnLog();
  815. ipnLog.setType("omg_query_error");
  816. ipnLog.setIpnLog(JSON.toJSONString(errLog));
  817. ipnLogService.insertIpnLog(ipnLog);
  818. // OMG 按 MerchantID 限流(HTTP 403)时停止遍历,避免连发罚(T055 令牌桶节流完整前的过渡)
  819. if (e.getMessage() != null && e.getMessage().contains("HTTP 403")) {
  820. log.warn("[OMG-{}] queryTrade 触发 OMG 403 限流,停止本单遍历(下轮重试): ddId={}", source, ddId);
  821. return new int[]{lastStatus, 0};
  822. }
  823. continue;
  824. }
  825. String tradeStatus = resp.get("TradeStatus");
  826. if ("1".equals(tradeStatus)) {
  827. String tradeNo = resp.get("TradeNo");
  828. String paymentType = resp.get("PaymentType");
  829. int respAmt = toInt(resp.get("TradeAmt"), -1);
  830. if (tradeNo == null || tradeNo.isEmpty()
  831. || p.getAmount() == null || respAmt != p.getAmount()
  832. || order.getAmount() == null || order.getAmount().intValue() != p.getAmount()) {
  833. log.error("[OMG-{}] 补单金额/字段校验失败: ddId={}, mtcn={}, paymentAmt={}, orderAmt={}, queryAmt={}, tradeNo={}",
  834. source, ddId, p.getMerchantTradeNo(), p.getAmount(), order.getAmount(), respAmt, tradeNo);
  835. orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单金额/字段不符,需人工核对");
  836. continue;
  837. }
  838. Date payTime = parsePayTime(resp.get("PaymentDate"));
  839. Date tradeDate = parsePayTime(resp.get("TradeDate"));
  840. if (payTime == null || tradeDate == null) {
  841. log.warn("[OMG-{}] 补单响应日期非法: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo());
  842. continue;
  843. }
  844. applyPaidResult(p, order, tradeNo, paymentType, 1, resp.get("RtnMsg"),
  845. null, payTime, tradeDate, auditJson(resp));
  846. orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单成功");
  847. log.info("[OMG-{}] 补单成功: ddId={}, mtcn={}, tradeNo={}", source, ddId, p.getMerchantTradeNo(), tradeNo);
  848. return new int[]{1, 1};
  849. } else if ("10200095".equals(tradeStatus)) {
  850. String tradeNo = resp.get("TradeNo");
  851. if (tradeNo == null || tradeNo.isEmpty()) {
  852. // 从未与 OMG 建立交易(未取号)→ markFail(0→2,CAS 允许 2→1 复活)
  853. paymentService.markFail(p.getId(), null, "OMG查询返回失败(10200095)", auditJson(resp));
  854. orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单:交易失败(10200095),mtcn=" + p.getMerchantTradeNo());
  855. lastStatus = 2;
  856. } else {
  857. // 已取号(可能 OMG 延迟建案/结算滞后)→ 不标失败,下轮再查
  858. log.warn("[OMG-{}] queryTrade 返回 10200095 但已取号(tradeNo={}),不标失败: ddId={}, mtcn={}",
  859. source, tradeNo, ddId, p.getMerchantTradeNo());
  860. }
  861. } else {
  862. // TradeStatus=0 未付(延期支付付款前常态)或未知 → no-op,定时任务下轮再查
  863. log.warn("[OMG-{}] queryTrade 返回未付/未知: ddId={}, mtcn={}, TradeStatus={}",
  864. source, ddId, p.getMerchantTradeNo(), tradeStatus);
  865. }
  866. }
  867. return new int[]{lastStatus, 0};
  868. }
  869. /**
  870. * 应用「已支付」结果(notify 回调成功分支、/query 被动补单、定时补单 三处共用)。
  871. * 幂等:markSuccess 按 trade_no CAS,已 pay_status=1 返回 0 → 不重复改单/推送。
  872. * 若订单已进终态(取消/退款),只记录资金事实不触发发货推送(需人工退款/核对)。
  873. */
  874. private void applyPaidResult(PosOrderOmgPayment payment, PosOrder order, String tradeNo,
  875. String payType, int rtnCode, String rtnMsg, String authCode,
  876. Date payTime, Date tradeDate, String callbackRaw) {
  877. boolean terminalOrder = (order.getState() != null && order.getState() == 4L)
  878. || (order.getPayStatus() != null && order.getPayStatus() == 2L);
  879. int n = paymentService.markSuccess(payment.getId(), tradeNo, payType, rtnCode, rtnMsg,
  880. authCode, payTime, tradeDate, callbackRaw);
  881. if (n <= 0) {
  882. return;
  883. }
  884. if (terminalOrder) {
  885. recordPaidOrderWithoutFulfillment(order,
  886. "OMG支付成功晚于订单取消/退款,需立即退款或人工核对");
  887. log.error("OMG late successful payment requires refund/reconciliation: ddId={}, tradeNo={}",
  888. order.getDdId(), tradeNo);
  889. } else {
  890. // 首次成功:原子核销订单后,在事务提交后推送用户/商家
  891. handlePaymentSuccess(order);
  892. }
  893. }
  894. // ============================ 支付成功业务链路(参照 PosOrderController.sendHdfkMessage 货到付款同款) ============================
  895. /** 支付成功:原子更新订单支付状态并写日志;事务提交后再推送,避免外部调用干扰账务事务。 */
  896. private void handlePaymentSuccess(PosOrder order) {
  897. PosOrder upd = new PosOrder();
  898. upd.setPayStatus(1L);
  899. boolean updated = posOrderService.update(upd, new UpdateWrapper<PosOrder>()
  900. .eq("id", order.getId())
  901. .eq("state", 0)
  902. .eq("pay_status", 0));
  903. if (!updated) {
  904. PosOrder latest = posOrderService.getById(order.getId());
  905. if (latest != null && (Long.valueOf(1L).equals(latest.getPayStatus())
  906. || Long.valueOf(2L).equals(latest.getPayStatus())
  907. || Long.valueOf(4L).equals(latest.getState()))) {
  908. recordPaidOrderWithoutFulfillment(latest,
  909. "OMG支付核销遇到订单状态并发变化,需退款或人工核对");
  910. return;
  911. }
  912. throw new IllegalStateException("原子更新OMG订单支付状态失败");
  913. }
  914. orderLogHelper.logSync(String.valueOf(order.getDdId()), 0, null, "系统", "系统收到OMG支付成功回调");
  915. runAfterCommit(() -> pushPaymentSuccess(order));
  916. }
  917. /** 只记录资金事实,不改变订单业务状态、不触发发货推送。 */
  918. private void recordPaidOrderWithoutFulfillment(PosOrder order, String logContent) {
  919. PosOrder upd = new PosOrder();
  920. upd.setId(order.getId());
  921. upd.setPayStatus(1L);
  922. if (!posOrderService.saveOrUpdate(upd)) {
  923. throw new IllegalStateException("补偿OMG订单支付状态失败");
  924. }
  925. orderLogHelper.logSync(String.valueOf(order.getDdId()), 0, null, "系统", logContent);
  926. }
  927. private void runAfterCommit(Runnable action) {
  928. if (!TransactionSynchronizationManager.isSynchronizationActive()) {
  929. action.run();
  930. return;
  931. }
  932. TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
  933. @Override
  934. public void afterCommit() {
  935. action.run();
  936. }
  937. });
  938. }
  939. private void pushPaymentSuccess(PosOrder order) {
  940. try {
  941. String ddId = String.valueOf(order.getDdId());
  942. String title = MessageUtils.message("no.message.push.message");
  943. String body = OrderPushBodyDto.getJson(ddId, "0", 0);
  944. InfoUser user = order.getUserId() == null ? null : infoUserService.getById(order.getUserId());
  945. if (user != null) {
  946. PayPush push = new PayPush();
  947. push.apppush(user.getCid(), title, MessageUtils.message("no.message.push.payment.success"), body);
  948. pushEventService.PublisherEvent(user.getUserId(), title,
  949. MessageUtils.message("no.message.push.payment.success"), body);
  950. }
  951. InfoUser sh = order.getShId() == null ? null : infoUserService.getById(order.getShId());
  952. if (sh != null) {
  953. PayPush push = new PayPush();
  954. push.shpush(sh.getCid(), title, MessageUtils.message("no.message.push.new.order"), body);
  955. pushEventService.PublisherEvent(sh.getUserId(), title,
  956. MessageUtils.message("no.message.push.new.order"), body);
  957. }
  958. } catch (Exception e) {
  959. log.error("OMG 支付成功推送异常: ddId={}", order.getDdId(), e);
  960. }
  961. }
  962. // ============================ 辅助 ============================
  963. private String callbackIp(String forwardedFor) {
  964. if (forwardedFor == null || forwardedFor.isBlank()) {
  965. return "unknown";
  966. }
  967. String firstIp = forwardedFor.split(",", 2)[0].trim();
  968. return firstIp.isEmpty() || firstIp.length() > 64 ? "unknown" : firstIp;
  969. }
  970. private boolean invalidOrderRequest(OmgOrderRequest request) {
  971. if (request == null || request.getOrderid() == null) {
  972. return true;
  973. }
  974. String orderid = request.getOrderid();
  975. return orderid.trim().isEmpty();
  976. }
  977. private int toInt(String s, int def) {
  978. if (s == null || s.isEmpty()) {
  979. return def;
  980. }
  981. try {
  982. return Integer.parseInt(s);
  983. } catch (Exception e) {
  984. return def;
  985. }
  986. }
  987. /** 解析 callbackRaw(JSON)为 Map,供前端读 ATM/超商取号信息。 */
  988. private Map<String, String> parseJsonObject(String json) {
  989. Map<String, String> map = new LinkedHashMap<>();
  990. if (json == null || json.isEmpty()) {
  991. return map;
  992. }
  993. try {
  994. com.alibaba.fastjson2.JSONObject obj = JSON.parseObject(json);
  995. for (String k : obj.keySet()) {
  996. Object v = obj.get(k);
  997. map.put(k, v == null ? "" : v.toString());
  998. }
  999. } catch (Exception e) {
  1000. log.warn("解析 callbackRaw JSON 失败", e);
  1001. }
  1002. return map;
  1003. }
  1004. /** 取号页只返回支付凭证字段,避免把信用卡额外回传参数暴露给客户端。 */
  1005. private Map<String, String> parsePaymentInfo(String json) {
  1006. Set<String> allowed = Set.of("BankCode", "vAccount", "PaymentNo", "ExpireDate",
  1007. "PaymentType", "TradeNo", "MerchantTradeNo", "RtnCode", "RtnMsg",
  1008. "CVSStoreID", "CVSStoreName", "PayFrom", "Barcode1", "Barcode2", "Barcode3");
  1009. Map<String, String> raw = parseJsonObject(json);
  1010. raw.keySet().removeIf(key -> !allowed.contains(key));
  1011. return raw;
  1012. }
  1013. private String auditJson(Map<String, String> form) {
  1014. Map<String, String> audit = new LinkedHashMap<>(form);
  1015. // audit.remove("CheckMacValue");
  1016. // audit.remove("AuthCode");
  1017. // audit.remove("auth_code");
  1018. // audit.remove("card4no");
  1019. // audit.remove("card6no");
  1020. return JSON.toJSONString(audit);
  1021. }
  1022. /** 严格解析 OMG 回调日期;非法日期返回 null,避免把坏数据伪装成当前时间。 */
  1023. private Date parsePayTime(String s) {
  1024. if (s == null || s.isEmpty()) {
  1025. return null;
  1026. }
  1027. String[] fmts = {"yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss"};
  1028. for (String f : fmts) {
  1029. try {
  1030. SimpleDateFormat format = new SimpleDateFormat(f);
  1031. format.setLenient(false);
  1032. format.setTimeZone(TimeZone.getTimeZone("Asia/Taipei"));
  1033. return format.parse(s);
  1034. } catch (Exception ignore) {
  1035. }
  1036. }
  1037. return null;
  1038. }
  1039. }