package com.ruoyi.app.pay; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.alibaba.fastjson2.JSON; import com.ruoyi.app.order.dto.OrderPushBodyDto; import com.ruoyi.app.order.OrderLifecycleService; import com.ruoyi.app.pay.dto.OmgCallbackRequest; import com.ruoyi.app.pay.dto.OmgOrderRequest; import com.ruoyi.app.pay.dto.OmgRefundOutcome; import com.ruoyi.app.utils.PayPush; import com.ruoyi.app.utils.event.PushEventService; import com.ruoyi.app.utils.omg.OmgCheckMacValue; import com.ruoyi.app.utils.omg.OmgPay; import com.ruoyi.app.utils.omg.OmgPayConfig; import com.ruoyi.app.utils.omg.OmgQueryThrottle; import com.ruoyi.common.annotation.Anonymous; import com.ruoyi.common.annotation.RepeatSubmit; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.system.domain.InfoUser; import com.ruoyi.system.domain.IpnLog; import com.ruoyi.system.domain.PosOrder; import com.ruoyi.system.domain.PosOrderOmgPayment; import com.ruoyi.system.domain.PosStoreOmg; import com.ruoyi.system.service.IInfoUserService; import com.ruoyi.system.service.IIpnLogService; import com.ruoyi.system.service.IPosOrderOmgPaymentService; import com.ruoyi.system.service.IPosOrderOmgRefundService; import com.ruoyi.system.service.IPosOrderService; import com.ruoyi.system.service.IPosStoreOmgService; import com.ruoyi.system.utils.Auth; import com.ruoyi.system.utils.JwtUtil; import com.ruoyi.system.utils.OrderLogHelper; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.bind.annotation.*; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TimeZone; import java.util.UUID; /** * OMG(歐買尬/FunPoint) AIO 线上支付 Controller(独立于 newebpay,零蓝新依赖)。 * *

本期实现: *

* * @author ruoyi * @date 2026-07-29 */ @RestController @RequestMapping("/pay/omg") public class OmgPayController extends BaseController { private static final Logger log = LoggerFactory.getLogger(OmgPayController.class); /** payType 取值:OMG 在线支付(发起时写入 pos_order.pay_type;具体方式如 Credit_CreditCard、ATM 系列、CVS 系列等由回调写入 pos_order_omg_payment.pay_type)。 */ public static final String PAY_TYPE_OMG = "2"; @Autowired private IPosOrderService posOrderService; @Autowired private IPosStoreOmgService storeOmgService; @Autowired private IPosOrderOmgPaymentService paymentService; @Autowired private OmgPay omgPay; @Autowired private IIpnLogService ipnLogService; @Autowired private IInfoUserService infoUserService; @Autowired private PushEventService pushEventService; @Autowired private OrderLogHelper orderLogHelper; @Autowired private IPosOrderOmgRefundService refundService; @Autowired private OrderLifecycleService orderLifecycleService; @Autowired private PaymentCreateGuardService paymentCreateGuardService; @Autowired private OmgQueryThrottle omgQueryThrottle; @Value("${omg.base-url}") private String baseUrl; @Value("${omg.return-url}") private String returnUrl; @Value("${omg.order-result-url}") private String orderResultUrl; @Value("${omg.payment-info-url}") private String paymentInfoUrl; /** create 复用新鲜期(分钟):窗口内且 trade_no 为空的活跃行复用 MTN,超期则轮换新建(T058 防堆积,stage 实测调整)。 */ @Value("${omg.create.reuse-fresh-minutes:3}") private int reuseFreshMinutes; // ============================ US1:发起 AIO 幕前支付 ============================ /** * 发起 OMG AIO 幕前支付。校验订单归属/未支付/金额 → 查门店启用凭证 → 生成 MerchantTradeNo → * 组参 + CheckMacValue → 落流水(pay_status=0) + 更新订单 payType=PAY_TYPE_OMG/payUrl → 返回 form 字段供前端 Form Post。 */ @Anonymous @Auth @RepeatSubmit(interval = 1000, message = "请求过于频繁") @PostMapping("/create") @Transactional(rollbackFor = Exception.class) public AjaxResult create(@RequestHeader String token, @RequestBody(required = false) OmgOrderRequest request) { if (invalidOrderRequest(request)) { return error(MessageUtils.message("no.order.id.error")); } // 按 ddId 串行化发起(T058 防堆积):同一订单并发 create 在分布式锁内排队,锁释放在事务提交后, // 保证后一个请求能看到前一个已提交的活跃流水 → 命中复用而非新建(详见 PaymentCreateGuardService)。 return paymentCreateGuardService == null ? createUnderLock(token, request) : paymentCreateGuardService.withLock(request.getOrderid(), () -> createUnderLock(token, request)); } /** * 发起支付的实际业务逻辑,在 {@link #create} 的 {@code @Transactional} 与 {@link PaymentCreateGuardService} * 分布式锁内执行。锁释放在事务 afterCommit,确保并发请求看不到未提交的活跃流水 INSERT,避免重复活跃行。 * *

流程:校验登录/订单归属/未支付/金额/门店 → 查门店 OMG 凭证 → 复用或新建 MerchantTradeNo → * 组 AIO 参 + CheckMacValue → 落流水(pay_status=0) + 更新订单 payType/payUrl → 返回 form 字段。 */ private AjaxResult createUnderLock(String token, OmgOrderRequest request) { if (invalidOrderRequest(request)) { return error(MessageUtils.message("no.order.id.error")); } String orderid = request.getOrderid(); String userId; try { userId = new JwtUtil().getusid(token); } catch (Exception e) { return error(MessageUtils.message("no.order.id.error")); } if (userId == null || userId.isEmpty()) { return error("请先登录"); } PosOrder order = posOrderService.getOne(new QueryWrapper().eq("dd_id", orderid)); if (order == null) { return error(MessageUtils.message("no.order.id.error")); } if (order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) { return error("无权操作该订单"); } if (!PAY_TYPE_OMG.equals(order.getPayType())) { return error("订单支付方式不是 OMG"); } if (order.getState() != null && order.getState() == 4L) { return error("订单已取消,不可重新支付"); } if (order.getPayStatus() != null && order.getPayStatus() == 1L) { return error("订单已支付"); } if (order.getAmount() == null || order.getAmount() <= 0) { return error("订单金额异常"); } if (order.getMdId() == null) { return error("订单门店缺失"); } // 门店 OMG 凭证(已开通且启用)→ 转 OmgPayConfig(Controller 层完成,避免 system→admin 反向依赖) PosStoreOmg cred = storeOmgService.getEnabledCredential(order.getMdId()); if (cred == null) { return error("该门店暂不支持线上支付"); } OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv()); String merchantTradeNo = resolveMerchantTradeNo(order, cred); // OMG AIO 参数(contracts/api.md §A1/B1);InvoiceMark=N 固定(发票走 ezPay);EncryptType=1 固定(SHA256) SimpleDateFormat fmt = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"); fmt.setTimeZone(TimeZone.getTimeZone("Asia/Taipei")); Map params = new LinkedHashMap<>(); params.put("MerchantID", cred.getMerchantId()); params.put("MerchantTradeNo", merchantTradeNo); params.put("MerchantTradeDate", fmt.format(new Date())); params.put("PaymentType", "aio"); params.put("TotalAmount", String.valueOf(order.getAmount())); params.put("TradeDesc", "food order " + orderid); params.put("ItemName", "order " + orderid); params.put("ReturnURL", returnUrl); params.put("ChoosePayment", "ALL"); params.put("EncryptType", "1"); params.put("InvoiceMark", "N"); params.put("NeedExtraPaidInfo", "Y"); if (paymentInfoUrl != null && !paymentInfoUrl.isEmpty()) { params.put("PaymentInfoURL", paymentInfoUrl); } if (orderResultUrl != null && !orderResultUrl.isEmpty()) { params.put("OrderResultURL", orderResultUrl); } // 组参 + CheckMacValue,返回含 gatewayUrl 的 form 字段 Map form = omgPay.createAioForm(baseUrl, cfg, params); // 更新订单 payType=OMG(PAY_TYPE_OMG) / payUrl=gatewayUrl(仅更这两个字段) PosOrder upd = new PosOrder(); upd.setId(order.getId()); upd.setPayType(PAY_TYPE_OMG); upd.setPayUrl(form.get("gatewayUrl")); posOrderService.saveOrUpdate(upd); log.info("[OMG] create orderid={}, merchantTradeNo={}, amount={}, gatewayUrl={}", orderid, merchantTradeNo, order.getAmount(), form.get("gatewayUrl")); return success(form); } /** * 解析本次发起用的 MerchantTradeNo(T058 防堆积核心)。 * *

优先复用新鲜期内活跃未付且未取号(trade_no IS NULL)的旧 MTN(连点/重发起命中 → 不新建流水); * 未命中则先把旧行轮换为历史(is_active 1→0,pay_status 不动,保留接迟到 notify 与审计),再新建活跃行。 * 必须在 create 的 @Transactional 与 PaymentCreateGuardService 分布式锁内调用。 */ private String resolveMerchantTradeNo(PosOrder order, PosStoreOmg cred) { String ddId = String.valueOf(order.getDdId()); PosOrderOmgPayment reusable = paymentService.getActiveForReuse(ddId, reuseFreshMinutes); if (reusable != null) { log.info("[OMG] create reuse orderid={}, merchantTradeNo={} (fresh within {}min, trade_no null)", order.getDdId(), reusable.getMerchantTradeNo(), reuseFreshMinutes); return reusable.getMerchantTradeNo(); } paymentService.markActiveHistorical(ddId); return createPaymentAttempt(order, cred); } /** * MerchantTradeNo 生成:OMG 要求全平台永久唯一、≤20 字元、英数大小写混合,且不可复用。 * 使用 "OMG" + UUID 随机片段,并依靠数据库唯一索引与冲突重试兜底;ddId 由支付流水反查。 */ private String createPaymentAttempt(PosOrder order, PosStoreOmg cred) { for (int attempt = 0; attempt < 3; attempt++) { String merchantTradeNo = genMerchantTradeNo(); try { paymentService.createPayment(String.valueOf(order.getDdId()), merchantTradeNo, order.getMdId(), cred.getMerchantId(), order.getAmount(), "ALL"); return merchantTradeNo; } catch (DuplicateKeyException e) { if (attempt == 2) { throw e; } } } throw new IllegalStateException("Unable to allocate OMG MerchantTradeNo"); } private String genMerchantTradeNo() { String random = UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT); return "OMG" + random.substring(0, 17); } // ============================ US2:ReturnURL 支付结果回调 ============================ /** * OMG 支付结果回调(@Anonymous,OMG 服务端 Form Post)。OMG 明文参数 + 单 CheckMacValue(无 AES 解密)。 * *

记 IPN → 按 MerchantID 查凭证 → 验签 → trade_no 幂等 → 金额校验 → RtnCode==1 且 SimulatePaid!=1 * → markSuccess + 推送 → 回纯串 {@code 1|OK}(注意:非 JSON,与蓝新不同)。任何异常/校验失败都仍回 1|OK * (OMG 收到非成功会重试),但绝不错误更新订单。 */ @Anonymous @PostMapping(value = "/notify", produces = "text/plain;charset=UTF-8") @Transactional(rollbackFor = Exception.class) public String notify(@ModelAttribute OmgCallbackRequest callback, @RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor) { Map form; try { form = callback.toParameterMap(); } catch (IllegalArgumentException e) { log.warn("OMG callback rejected: {}", e.getMessage()); return "1|OK"; } // 记录 IPN 日志 try { IpnLog ipnLog = new IpnLog(); ipnLog.setIp(callbackIp(forwardedFor)); ipnLog.setIpnLog(auditJson(form)); ipnLog.setType("omg"); ipnLogService.insertIpnLog(ipnLog); } catch (Exception e) { log.warn("记 OMG IPN 日志失败", e); } String merchantId = form.get("MerchantID"); if (merchantId == null || merchantId.isEmpty()) { log.warn("OMG callback missing MerchantID"); return "1|OK"; } PosStoreOmg cred = storeOmgService.getCredentialByMerchantId(merchantId); if (cred == null) { log.warn("OMG 回调无匹配凭证: merchantId={}", merchantId); return "1|OK"; } // 验签(OMG 无解密,直接对全部回调参 + CheckMacValue 重算比对) if (!OmgCheckMacValue.verify(form, cred.getHashKey(), cred.getHashIv())) { log.warn("OMG 回调验签失败: merchantId={}", merchantId); return "1|OK"; } String tradeNo = form.get("TradeNo"); String merchantTradeNo = form.get("MerchantTradeNo"); if (tradeNo == null || tradeNo.isEmpty() || merchantTradeNo == null || merchantTradeNo.isEmpty()) { log.warn("OMG callback missing trade identifiers: merchantId={}", merchantId); return "1|OK"; } int rtnCode = toInt(form.get("RtnCode"), -1); int tradeAmt = toInt(form.get("TradeAmt"), -1); String simulatePaid = form.get("SimulatePaid"); String paymentType = form.get("PaymentType"); String paymentDate = form.get("PaymentDate"); // 订单关联:由 MerchantTradeNo 反查流水与订单 PosOrderOmgPayment payment = paymentService.getByMerchantTradeNo(merchantTradeNo); if (payment == null) { log.warn("OMG 回调无对应发起记录: merchantTradeNo={}", merchantTradeNo); return "1|OK"; } if (!merchantId.equals(payment.getMerchantId()) || payment.getStoreId() == null || cred.getStoreId() == null || !payment.getStoreId().equals(cred.getStoreId())) { log.warn("OMG callback credential/ledger mismatch: merchantId={}, merchantTradeNo={}", merchantId, merchantTradeNo); return "1|OK"; } PosOrderOmgPayment exist = paymentService.getByTradeNo(tradeNo); if (exist != null && !payment.getId().equals(exist.getId())) { log.warn("OMG TradeNo already belongs to another payment: tradeNo={}", tradeNo); return "1|OK"; } String ddId = payment.getDdId(); PosOrder order = posOrderService.getOne(new QueryWrapper().eq("dd_id", ddId)); if (order == null) { log.warn("OMG 回调订单不存在: ddId={}", ddId); return "1|OK"; } if (order.getMdId() == null || !order.getMdId().equals(payment.getStoreId()) || !order.getMdId().equals(cred.getStoreId())) { log.warn("OMG callback order/store mismatch: ddId={}", ddId); return "1|OK"; } // 金额校验(以平台实际应收金额为准) if (payment.getAmount() == null || order.getAmount() == null || tradeAmt != payment.getAmount() || order.getAmount().intValue() != payment.getAmount()) { log.error("OMG callback amount mismatch: ddId={}, paymentAmt={}, orderAmt={}, callbackAmt={}", ddId, payment.getAmount(), order.getAmount(), tradeAmt); return "1|OK"; } boolean terminalOrder = (order.getState() != null && order.getState() == 4L) || (order.getPayStatus() != null && order.getPayStatus() == 2L); // 流水已成功但订单未核销时,重复回调用于补偿上一次订单更新失败。 if (Integer.valueOf(1).equals(payment.getPayStatus())) { if (!tradeNo.equals(payment.getTradeNo())) { log.warn("OMG paid callback TradeNo mismatch: merchantTradeNo={}", merchantTradeNo); return "1|OK"; } if (payment.getPayType() != null && paymentType != null && !paymentType.equals(payment.getPayType())) { log.warn("OMG paid callback PaymentType mismatch: merchantTradeNo={}", merchantTradeNo); return "1|OK"; } if (rtnCode == 1 && !"1".equals(simulatePaid) && !Long.valueOf(1L).equals(order.getPayStatus())) { recordPaidOrderWithoutFulfillment(order, terminalOrder ? "OMG支付成功回调晚于订单取消/退款,需立即退款或人工核对" : "系统补偿OMG已支付流水与订单支付状态不一致"); } return "1|OK"; } if (rtnCode == 1 && !"1".equals(simulatePaid)) { Date payTime = parsePayTime(paymentDate); Date tradeDate = parsePayTime(form.get("TradeDate")); if (payTime == null || tradeDate == null) { log.warn("OMG callback contains invalid date: merchantTradeNo={}", merchantTradeNo); return "1|OK"; } String authCode = form.get("auth_code"); if (authCode == null) { authCode = form.get("AuthCode"); } // 核销成功(notify/补单共用:幂等 markSuccess + 订单状态流转 + 推送) applyPaidResult(payment, order, tradeNo, paymentType, rtnCode, form.get("RtnMsg"), authCode, payTime, tradeDate, auditJson(form)); } else if (rtnCode != 1) { paymentService.markFail(payment.getId(), rtnCode, form.get("RtnMsg"), auditJson(form)); log.warn("OMG 回调交易失败: ddId={}, rtnCode={}, rtnMsg={}", ddId, rtnCode, form.get("RtnMsg")); } else { // SimulatePaid=1 模拟支付:不发货,仅记录 log.warn("OMG 回调为模拟支付(SimulatePaid=1),不发货: ddId={}", ddId); } return "1|OK"; } /** * 支付完成返回页(@Anonymous)。仅 302 引导回前端结果页(带 ddId),改订单状态(以 notify 为准)。 */ @Anonymous @RequestMapping(value = "/return", method = {RequestMethod.GET, RequestMethod.POST}) public void returnCallback(@ModelAttribute OmgCallbackRequest callback, HttpServletResponse response) throws IOException { String ddId = ""; try { Map form = callback.toParameterMap(); String mtn = form.get("MerchantTradeNo"); // 由 MerchantTradeNo 反查流水拿 ddId(MTN 不再编码 ddId) if (mtn != null && !mtn.isEmpty()) { PosOrderOmgPayment p = paymentService.getByMerchantTradeNo(mtn); if (p != null && p.getDdId() != null) { ddId = p.getDdId(); } } } catch (Exception e) { log.warn("OMG ReturnURL 解析失败", e); } if (orderResultUrl == null || orderResultUrl.isEmpty()) { log.warn("OMG ReturnURL 未配置 omg.order-result-url,无法重定向"); response.setStatus(204); return; } String sep = orderResultUrl.contains("?") ? "&" : "?"; response.sendRedirect(orderResultUrl + sep + "ddId=" + URLEncoder.encode(ddId == null ? "" : ddId, StandardCharsets.UTF_8)); } // ============================ US3:ATM/超商 取号回调 + 取号查询 ============================ /** * OMG ATM/超商取号回调(@Anonymous,PaymentInfoURL)。验签后把虚帐/缴费码原始报文 * (BankCode/vAccount/ExpireDate 或 PaymentNo/ExpireDate 等)落到流水的 callbackRaw,不改 pay_status; * 实际付款后 OMG 再回调 /pay/omg/notify(RtnCode=1)走 US2 核销。回纯串 {@code 1|OK}。 */ @Anonymous @PostMapping(value = "/paymentInfo", produces = "text/plain;charset=UTF-8") public String paymentInfoCallback(@ModelAttribute OmgCallbackRequest callback, @RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor) { Map form; try { form = callback.toParameterMap(); } catch (IllegalArgumentException e) { log.warn("OMG paymentInfo rejected: {}", e.getMessage()); return "1|OK"; } try { IpnLog ipnLog = new IpnLog(); ipnLog.setIp(callbackIp(forwardedFor)); ipnLog.setIpnLog(auditJson(form)); ipnLog.setType("omg"); ipnLogService.insertIpnLog(ipnLog); } catch (Exception e) { log.warn("记 OMG paymentInfo IPN 日志失败", e); } String merchantId = form.get("MerchantID"); if (merchantId == null || merchantId.isEmpty()) { log.warn("OMG paymentInfo missing MerchantID"); return "1|OK"; } PosStoreOmg cred = storeOmgService.getCredentialByMerchantId(merchantId); if (cred == null) { log.warn("OMG paymentInfo 无匹配凭证: merchantId={}", merchantId); return "1|OK"; } if (!OmgCheckMacValue.verify(form, cred.getHashKey(), cred.getHashIv())) { log.warn("OMG paymentInfo 验签失败: merchantId={}", merchantId); return "1|OK"; } String merchantTradeNo = form.get("MerchantTradeNo"); PosOrderOmgPayment payment = paymentService.getByMerchantTradeNo(merchantTradeNo); if (payment == null) { log.warn("OMG paymentInfo 无对应发起记录: merchantTradeNo={}", merchantTradeNo); return "1|OK"; } if (!merchantId.equals(payment.getMerchantId()) || payment.getStoreId() == null || cred.getStoreId() == null || !payment.getStoreId().equals(cred.getStoreId())) { log.warn("OMG paymentInfo credential/ledger mismatch: merchantTradeNo={}", merchantTradeNo); return "1|OK"; } // 取号成功 RtnCode:ATM=2,CVS/BarcodeATM=10100073(取号本身非付款;付款成功另走 /notify 的 RtnCode=1) int pickupRtnCode = toInt(form.get("RtnCode"), -1); if (pickupRtnCode != 2 && pickupRtnCode != 10100073) { log.warn("OMG paymentInfo 取号非成功 rtnCode={}, merchantTradeNo={}", pickupRtnCode, merchantTradeNo); return "1|OK"; } // 落取号信息(BankCode/vAccount/ExpireDate 或 PaymentNo/ExpireDate)为 JSON,不改 pay_status paymentService.markPaymentInfo(payment.getId(), form.get("TradeNo"), auditJson(form)); log.info("[OMG] paymentInfo 已记录取号信息: ddId={}, merchantTradeNo={}", payment.getDdId(), merchantTradeNo); return "1|OK"; } /** * 取号信息查询(前端 ATM/超商结果页展示虚帐/缴费码 + 期限用,@Auth)。 * 返回 payType/amount/payStatus + 解析后的取号字段 info。 */ @Anonymous @Auth @GetMapping("/paymentInfo/{orderid}") public AjaxResult getPaymentInfo(@RequestHeader String token, @PathVariable String orderid) { String userId; try { userId = new JwtUtil().getusid(token); } catch (Exception e) { return error(MessageUtils.message("no.order.id.error")); } if (userId == null || userId.isEmpty()) { return error("请先登录"); } PosOrder order = posOrderService.getOne(new QueryWrapper().eq("dd_id", orderid)); if (order == null || order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) { return error("无权操作该订单"); } // 优先取已付/退款行(展示回执);无则取最新行(未付款场景展示 ATM/超商虚帐) PosOrderOmgPayment p = paymentService.getLatestRefundableByDdId(orderid); if (p == null) { p = paymentService.getLatestByDdId(orderid); } if (p == null) { return error("无支付记录"); } Map result = new LinkedHashMap<>(); result.put("payType", p.getPayType()); result.put("amount", p.getAmount()); result.put("payStatus", p.getPayStatus()); result.put("info", parsePaymentInfo(p.getCallbackRaw())); return success(result); } // ============================ US4:订单取消与退款 ============================ /** * OMG 退款(@Auth,供取消链路/管理员调用)。信用卡(含 Apple Pay) 调 DoAction(Action=R 退刷); * ATM/超商/BarcodeATM 无退款 API → 记录待人工在 OMG 后台处理。仅正式端点可用(stage DoAction 不可用)。 */ @Anonymous @Auth @PostMapping("/refund") public AjaxResult refund(@RequestHeader String token, @RequestBody(required = false) OmgOrderRequest request) { if (invalidOrderRequest(request)) { return error(MessageUtils.message("no.order.id.error")); } String orderid = request.getOrderid(); String userId; try { userId = new JwtUtil().getusid(token); } catch (Exception e) { return error(MessageUtils.message("no.order.id.error")); } if (userId == null || userId.isEmpty()) { return error("请先登录"); } PosOrder order = posOrderService.getOne(new QueryWrapper().eq("dd_id", orderid)); if (order == null) { return error("订单不存在"); } if (order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) { return error("无权操作该订单"); } return refundOrder(order); } /** * OMG 退款核心(public,供取消链路 {@code cancelOrder} 直接调用,免重复实现)。 * 信用卡(含 Apple Pay) 调 DoAction(Action=R 退刷);ATM/超商/BarcodeATM 记人工。仅正式端点可用。 * 返回 success=已退款;error=需人工或失败(refund 已落记录可重试)。 */ public AjaxResult refundOrder(PosOrder order) { OmgRefundOutcome outcome = refundOrderOutcome(order); if (outcome.getStatus() == OmgRefundOutcome.Status.REFUNDED || outcome.getStatus() == OmgRefundOutcome.Status.IDEMPOTENT) { try { orderLifecycleService.finalizeSystemOmgRefund(order.getId()); return success(outcome.getMessage()); } catch (Exception e) { log.error("OMG refund succeeded but order state synchronization failed: orderId={}, errorType={}", order == null ? null : order.getId(), e.getClass().getSimpleName()); return error("OMG 退款成功,本地订单状态待同步"); } } return error(outcome.getMessage()); } /** * 返回明确退款结果,供管理端根据资金事实同步订单状态。 */ public OmgRefundOutcome refundOrderOutcome(PosOrder order) { if (order == null) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "订单不存在"); } if (!PAY_TYPE_OMG.equals(order.getPayType())) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "该订单非 OMG 支付"); } if (order.getPayStatus() == null || order.getPayStatus() != 1L) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "订单未支付,无需退款"); } String ddId = String.valueOf(order.getDdId()); PosOrderOmgPayment payment = paymentService.getLatestRefundableByDdId(ddId); if (payment == null) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无 OMG 支付流水"); } if (Integer.valueOf(3).equals(payment.getPayStatus())) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.IDEMPOTENT, "OMG 已完成退款"); } if (Integer.valueOf(4).equals(payment.getPayStatus())) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 退款结果待确认,请勿重复发起"); } if (!Integer.valueOf(1).equals(payment.getPayStatus())) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 支付流水不是已支付状态"); } int amount = payment.getAmount() == null ? 0 : payment.getAmount(); String payType = payment.getPayType(); if (amount <= 0 || payment.getTradeNo() == null || payment.getTradeNo().isEmpty()) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 支付流水不完整"); } if (order.getAmount() == null || order.getAmount().intValue() != amount) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 支付流水金额与订单不一致"); } // ATM/超商/BarcodeATM 无退款 API → 记录待人工 if (!"Credit_CreditCard".equals(payType)) { List records = refundService.listByPayment(payment.getId()); boolean pending = records != null && records.stream() .anyMatch(row -> row.getAction() == null && row.getRtnCode() == null); boolean completed = records != null && records.stream() .anyMatch(row -> row.getAction() == null && Integer.valueOf(1).equals(row.getRtnCode())); if (completed) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.IDEMPOTENT, "OMG 人工退款已确认完成"); } if (!pending) { refundService.record(payment.getId(), ddId, payment.getTradeNo(), null, amount, null, "延期支付方式无退款API,待人工在 OMG 后台处理", ""); } return OmgRefundOutcome.of(OmgRefundOutcome.Status.MANUAL_PENDING, "该支付方式需在 OMG 后台人工退款,订单暂保持已支付"); } if (paymentService.markRefunding(payment.getId()) == 0) { PosOrderOmgPayment latest = paymentService.getLatestRefundableByDdId(ddId); if (latest != null && Integer.valueOf(3).equals(latest.getPayStatus())) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.IDEMPOTENT, "OMG 已完成退款"); } return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "退款已处理或正在处理中"); } PosStoreOmg cred = storeOmgService.getCredentialByMerchantId(payment.getMerchantId()); if (cred == null || cred.getStoreId() == null || !cred.getStoreId().equals(payment.getStoreId())) { paymentService.restorePaidFromRefunding(payment.getId()); return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "门店 OMG 凭证不可用"); } OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv()); // 信用卡(含 Apple Pay)→ DoAction(Action=R 退刷);MVP 统一 R,失败再按状态分支(D7) refundService.record(payment.getId(), ddId, payment.getTradeNo(), "R", amount, null, "退款请求处理中", ""); Map resp; try { resp = omgPay.doAction(baseUrl, cfg, payment.getMerchantTradeNo(), payment.getTradeNo(), "R", amount); } catch (Exception e) { refundService.record(payment.getId(), ddId, payment.getTradeNo(), "R", amount, null, "退款结果未知,需对账", ""); log.error("OMG refund outcome unknown: ddId={}, paymentId={}, errorType={}", ddId, payment.getId(), e.getClass().getSimpleName()); return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 退款结果待确认,请勿重复发起"); } int rtnCode = toInt(resp == null ? null : resp.get("RtnCode"), -1); String rtnMsg = resp == null ? "" : resp.get("RtnMsg"); refundService.record(payment.getId(), ddId, payment.getTradeNo(), "R", amount, rtnCode, rtnMsg, resp == null ? "" : JSON.toJSONString(resp)); if (rtnCode == 1) { if (paymentService.markRefunded(payment.getId()) == 0) { log.error("OMG refund succeeded but ledger transition failed: paymentId={}", payment.getId()); return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 退款成功,账务状态待核对"); } orderLogHelper.logSync(ddId, 0, null, "系统", "OMG 信用卡退款成功"); return OmgRefundOutcome.of(OmgRefundOutcome.Status.REFUNDED, "退款成功"); } paymentService.restorePaidFromRefunding(payment.getId()); log.warn("OMG refund failed: ddId={}, paymentId={}, rtnCode={}", ddId, payment.getId(), rtnCode); return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "OMG 退款失败,请核对后重试"); } /** * 管理员已在 OMG 后台核实完成延期支付退款后,收口支付流水。 */ public OmgRefundOutcome confirmManualRefundOutcome(PosOrder order) { if (order == null || !PAY_TYPE_OMG.equals(order.getPayType())) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "该订单非 OMG 支付"); } String ddId = String.valueOf(order.getDdId()); PosOrderOmgPayment payment = paymentService.getLatestRefundableByDdId(ddId); if (payment == null || "Credit_CreditCard".equals(payment.getPayType())) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无可确认的 OMG 人工退款待办"); } List records = refundService.listByPayment(payment.getId()); boolean pending = records != null && records.stream() .anyMatch(row -> row.getAction() == null && row.getRtnCode() == null); boolean completed = records != null && records.stream() .anyMatch(row -> row.getAction() == null && Integer.valueOf(1).equals(row.getRtnCode())); if (!pending && !completed) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无可确认的 OMG 人工退款待办"); } if (Integer.valueOf(4).equals(payment.getPayStatus())) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 退款状态正在处理中"); } if (!Integer.valueOf(3).equals(payment.getPayStatus())) { if (!Integer.valueOf(1).equals(payment.getPayStatus()) || paymentService.markRefunding(payment.getId()) == 0 || paymentService.markRefunded(payment.getId()) == 0) { return OmgRefundOutcome.of(OmgRefundOutcome.Status.UNKNOWN, "OMG 人工退款状态发生变化,请刷新"); } } if (!completed) { refundService.record(payment.getId(), ddId, payment.getTradeNo(), null, payment.getAmount(), 1, "管理员确认已在 OMG 后台完成人工退款", ""); } return OmgRefundOutcome.of(OmgRefundOutcome.Status.REFUNDED, "已确认 OMG 人工退款完成"); } // ============================ US6:漏单补单(回调可靠性 / callback-reconcile.md) ============================ /** * 方案A 被动补单(@Auth)。前端结果页轮询仍 {@code payStatus=0} 时调用:后端查 OMG 真实状态并补单。 * 返回 {@code {payStatus:0未付/1已付/2失败, reconciled:true=本次触发补单}}。严格幂等(见 reconcileByQuery)。 */ @Anonymous @Auth @RepeatSubmit(interval = 2000, message = "查询过于频繁") @PostMapping("/query") public AjaxResult query(@RequestHeader String token, @RequestBody(required = false) OmgOrderRequest request) { if (invalidOrderRequest(request)) { return error(MessageUtils.message("no.order.id.error")); } String orderid = request.getOrderid(); String userId; try { userId = new JwtUtil().getusid(token); } catch (Exception e) { return error(MessageUtils.message("no.order.id.error")); } if (userId == null || userId.isEmpty()) { return error("请先登录"); } PosOrder order = posOrderService.getOne(new QueryWrapper().eq("dd_id", orderid)); if (order == null) { return error(MessageUtils.message("no.order.id.error")); } if (order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) { return error("无权操作该订单"); } if (!PAY_TYPE_OMG.equals(order.getPayType())) { return error("该订单非 OMG 支付"); } if (order.getState() != null && order.getState() == 4L) { return error("订单已取消"); } Map result = new LinkedHashMap<>(); Long payStatus = order.getPayStatus(); // 订单已核销/已退款 → 直接返回,不重复查 OMG if (payStatus != null && (payStatus == 1L || payStatus == 2L)) { result.put("payStatus", payStatus == 1L ? 1 : 2); result.put("reconciled", false); return success(result); } int[] res = reconcileByQuery(orderid, "query"); result.put("payStatus", res[0]); result.put("reconciled", res[1] == 1); return success(result); } /** * 查询 OMG 真实交易状态并按结果补单/标失败。{@code /query}(被动补单/方案A) 与定时任务(方案B)共用。 * *

幂等与自愈:流水已 {@code pay_status=1} 时,若订单未核销(跨事务中断残留)则补推订单状态, * 否则直接返回不重复处理;流水处于失败/退款终态不再查询。 * * @param ddId 订单号 * @param source 调用来源标记("query" 被动补单 / "scheduled" 定时补单),仅用于日志 * @return {@code int[2]} = {payStatus, reconciled}:payStatus 0未付/1已付/2失败;reconciled 1=本次触发补单核销 */ public int[] reconcileByQuery(String ddId, String source) { PosOrder order = posOrderService.getOne(new QueryWrapper().eq("dd_id", ddId)); if (order == null) { return new int[]{0, 0}; } if (order.getState() != null && order.getState() == 4L) { return new int[]{0, 0}; } // ① 自愈:流水已付(pay_status IN(1,3,4))但订单未核销(跨事务中断残留)→ 补推订单状态 PosOrderOmgPayment paid = paymentService.getLatestPaidByDdId(ddId); if (paid != null) { boolean isPaid = Integer.valueOf(1).equals(paid.getPayStatus()); if (isPaid && (order.getPayStatus() == null || order.getPayStatus() == 0L)) { handlePaymentSuccess(order); } return new int[]{isPaid ? 1 : 0, 0}; } // 闸3:per-ddId 扫描间隔节流(/query 60s / 定时 180s),挡前端轮询放大、防 OMG 403 自残(T055) if (!omgQueryThrottle.acquireDdIdSlot(ddId, source)) { log.debug("[OMG-{}] per-ddId 节流,跳过本轮 OMG 查询: ddId={}", source, ddId); return new int[]{order.getPayStatus() == null ? 0 : order.getPayStatus().intValue(), 0}; } // ② 遍历全量未付(pay_status=0,含历史行)queryTrade,任一已付即补单核销(补较早 MTN 如 id=26) List unpaid = paymentService.listUnpaidByDdId(ddId); if (unpaid == null || unpaid.isEmpty()) { return new int[]{0, 0}; } int lastStatus = 0; for (PosOrderOmgPayment p : unpaid) { PosStoreOmg cred = storeOmgService.getCredentialByMerchantIdAndStoreId(p.getMerchantId(), p.getStoreId()); if (cred == null) { log.error("[OMG-{}] 门店凭证不可用: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo()); continue; } // per-MerchantID 令牌桶节流(1token/3s,burst1):拿不到令牌跳过本行本轮,不阻塞(防 OMG 403) if (!omgQueryThrottle.tryAcquireMerchantToken(p.getMerchantId())) { log.debug("[OMG-{}] MerchantID 令牌桶限流,跳过本行本轮: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo()); continue; } OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv()); Map resp; try { resp = omgPay.queryTrade(baseUrl, cfg, p.getMerchantTradeNo()); } catch (Exception e) { log.error("[OMG-{}] queryTrade 失败(下轮重试): ddId={}, mtcn={}, err={}: {}", source, ddId, p.getMerchantTradeNo(), e.getClass().getName(), e.getMessage()); Map errLog = new LinkedHashMap<>(); errLog.put("ddId", ddId); errLog.put("mtcn", p.getMerchantTradeNo()); errLog.put("source", source); errLog.put("error", e.getClass().getName() + ": " + e.getMessage()); IpnLog ipnLog = new IpnLog(); ipnLog.setType("omg_query_error"); ipnLog.setIpnLog(JSON.toJSONString(errLog)); ipnLogService.insertIpnLog(ipnLog); // OMG 按 MerchantID 限流(HTTP 403)时停止遍历,避免连发罚(T055 令牌桶节流完整前的过渡) if (e.getMessage() != null && e.getMessage().contains("HTTP 403")) { log.warn("[OMG-{}] queryTrade 触发 OMG 403 限流,停止本单遍历(下轮重试): ddId={}", source, ddId); return new int[]{lastStatus, 0}; } continue; } String tradeStatus = resp.get("TradeStatus"); if ("1".equals(tradeStatus)) { String tradeNo = resp.get("TradeNo"); String paymentType = resp.get("PaymentType"); int respAmt = toInt(resp.get("TradeAmt"), -1); if (tradeNo == null || tradeNo.isEmpty() || p.getAmount() == null || respAmt != p.getAmount() || order.getAmount() == null || order.getAmount().intValue() != p.getAmount()) { log.error("[OMG-{}] 补单金额/字段校验失败: ddId={}, mtcn={}, paymentAmt={}, orderAmt={}, queryAmt={}, tradeNo={}", source, ddId, p.getMerchantTradeNo(), p.getAmount(), order.getAmount(), respAmt, tradeNo); orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单金额/字段不符,需人工核对"); continue; } Date payTime = parsePayTime(resp.get("PaymentDate")); Date tradeDate = parsePayTime(resp.get("TradeDate")); if (payTime == null || tradeDate == null) { log.warn("[OMG-{}] 补单响应日期非法: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo()); continue; } applyPaidResult(p, order, tradeNo, paymentType, 1, resp.get("RtnMsg"), null, payTime, tradeDate, auditJson(resp)); orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单成功"); log.info("[OMG-{}] 补单成功: ddId={}, mtcn={}, tradeNo={}", source, ddId, p.getMerchantTradeNo(), tradeNo); return new int[]{1, 1}; } else if ("10200095".equals(tradeStatus)) { String tradeNo = resp.get("TradeNo"); if (tradeNo == null || tradeNo.isEmpty()) { // 从未与 OMG 建立交易(未取号)→ markFail(0→2,CAS 允许 2→1 复活) paymentService.markFail(p.getId(), null, "OMG查询返回失败(10200095)", auditJson(resp)); orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单:交易失败(10200095),mtcn=" + p.getMerchantTradeNo()); lastStatus = 2; } else { // 已取号(可能 OMG 延迟建案/结算滞后)→ 不标失败,下轮再查 log.warn("[OMG-{}] queryTrade 返回 10200095 但已取号(tradeNo={}),不标失败: ddId={}, mtcn={}", source, tradeNo, ddId, p.getMerchantTradeNo()); } } else { // TradeStatus=0 未付(延期支付付款前常态)或未知 → no-op,定时任务下轮再查 log.warn("[OMG-{}] queryTrade 返回未付/未知: ddId={}, mtcn={}, TradeStatus={}", source, ddId, p.getMerchantTradeNo(), tradeStatus); } } return new int[]{lastStatus, 0}; } /** * 应用「已支付」结果(notify 回调成功分支、/query 被动补单、定时补单 三处共用)。 * 幂等:markSuccess 按 trade_no CAS,已 pay_status=1 返回 0 → 不重复改单/推送。 * 若订单已进终态(取消/退款),只记录资金事实不触发发货推送(需人工退款/核对)。 */ private void applyPaidResult(PosOrderOmgPayment payment, PosOrder order, String tradeNo, String payType, int rtnCode, String rtnMsg, String authCode, Date payTime, Date tradeDate, String callbackRaw) { boolean terminalOrder = (order.getState() != null && order.getState() == 4L) || (order.getPayStatus() != null && order.getPayStatus() == 2L); int n = paymentService.markSuccess(payment.getId(), tradeNo, payType, rtnCode, rtnMsg, authCode, payTime, tradeDate, callbackRaw); if (n <= 0) { return; } if (terminalOrder) { recordPaidOrderWithoutFulfillment(order, "OMG支付成功晚于订单取消/退款,需立即退款或人工核对"); log.error("OMG late successful payment requires refund/reconciliation: ddId={}, tradeNo={}", order.getDdId(), tradeNo); } else { // 首次成功:原子核销订单后,在事务提交后推送用户/商家 handlePaymentSuccess(order); } } // ============================ 支付成功业务链路(参照 PosOrderController.sendHdfkMessage 货到付款同款) ============================ /** 支付成功:原子更新订单支付状态并写日志;事务提交后再推送,避免外部调用干扰账务事务。 */ private void handlePaymentSuccess(PosOrder order) { PosOrder upd = new PosOrder(); upd.setPayStatus(1L); boolean updated = posOrderService.update(upd, new UpdateWrapper() .eq("id", order.getId()) .eq("state", 0) .eq("pay_status", 0)); if (!updated) { PosOrder latest = posOrderService.getById(order.getId()); if (latest != null && (Long.valueOf(1L).equals(latest.getPayStatus()) || Long.valueOf(2L).equals(latest.getPayStatus()) || Long.valueOf(4L).equals(latest.getState()))) { recordPaidOrderWithoutFulfillment(latest, "OMG支付核销遇到订单状态并发变化,需退款或人工核对"); return; } throw new IllegalStateException("原子更新OMG订单支付状态失败"); } orderLogHelper.logSync(String.valueOf(order.getDdId()), 0, null, "系统", "系统收到OMG支付成功回调"); runAfterCommit(() -> pushPaymentSuccess(order)); } /** 只记录资金事实,不改变订单业务状态、不触发发货推送。 */ private void recordPaidOrderWithoutFulfillment(PosOrder order, String logContent) { PosOrder upd = new PosOrder(); upd.setId(order.getId()); upd.setPayStatus(1L); if (!posOrderService.saveOrUpdate(upd)) { throw new IllegalStateException("补偿OMG订单支付状态失败"); } orderLogHelper.logSync(String.valueOf(order.getDdId()), 0, null, "系统", logContent); } private void runAfterCommit(Runnable action) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { action.run(); return; } TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { action.run(); } }); } private void pushPaymentSuccess(PosOrder order) { try { String ddId = String.valueOf(order.getDdId()); String title = MessageUtils.message("no.message.push.message"); String body = OrderPushBodyDto.getJson(ddId, "0", 0); InfoUser user = order.getUserId() == null ? null : infoUserService.getById(order.getUserId()); if (user != null) { PayPush push = new PayPush(); push.apppush(user.getCid(), title, MessageUtils.message("no.message.push.payment.success"), body); pushEventService.PublisherEvent(user.getUserId(), title, MessageUtils.message("no.message.push.payment.success"), body); } InfoUser sh = order.getShId() == null ? null : infoUserService.getById(order.getShId()); if (sh != null) { PayPush push = new PayPush(); push.shpush(sh.getCid(), title, MessageUtils.message("no.message.push.new.order"), body); pushEventService.PublisherEvent(sh.getUserId(), title, MessageUtils.message("no.message.push.new.order"), body); } } catch (Exception e) { log.error("OMG 支付成功推送异常: ddId={}", order.getDdId(), e); } } // ============================ 辅助 ============================ private String callbackIp(String forwardedFor) { if (forwardedFor == null || forwardedFor.isBlank()) { return "unknown"; } String firstIp = forwardedFor.split(",", 2)[0].trim(); return firstIp.isEmpty() || firstIp.length() > 64 ? "unknown" : firstIp; } private boolean invalidOrderRequest(OmgOrderRequest request) { if (request == null || request.getOrderid() == null) { return true; } String orderid = request.getOrderid(); return orderid.trim().isEmpty(); } private int toInt(String s, int def) { if (s == null || s.isEmpty()) { return def; } try { return Integer.parseInt(s); } catch (Exception e) { return def; } } /** 解析 callbackRaw(JSON)为 Map,供前端读 ATM/超商取号信息。 */ private Map parseJsonObject(String json) { Map map = new LinkedHashMap<>(); if (json == null || json.isEmpty()) { return map; } try { com.alibaba.fastjson2.JSONObject obj = JSON.parseObject(json); for (String k : obj.keySet()) { Object v = obj.get(k); map.put(k, v == null ? "" : v.toString()); } } catch (Exception e) { log.warn("解析 callbackRaw JSON 失败", e); } return map; } /** 取号页只返回支付凭证字段,避免把信用卡额外回传参数暴露给客户端。 */ private Map parsePaymentInfo(String json) { Set allowed = Set.of("BankCode", "vAccount", "PaymentNo", "ExpireDate", "PaymentType", "TradeNo", "MerchantTradeNo", "RtnCode", "RtnMsg", "CVSStoreID", "CVSStoreName", "PayFrom", "Barcode1", "Barcode2", "Barcode3"); Map raw = parseJsonObject(json); raw.keySet().removeIf(key -> !allowed.contains(key)); return raw; } private String auditJson(Map form) { Map audit = new LinkedHashMap<>(form); // audit.remove("CheckMacValue"); // audit.remove("AuthCode"); // audit.remove("auth_code"); // audit.remove("card4no"); // audit.remove("card6no"); return JSON.toJSONString(audit); } /** 严格解析 OMG 回调日期;非法日期返回 null,避免把坏数据伪装成当前时间。 */ private Date parsePayTime(String s) { if (s == null || s.isEmpty()) { return null; } String[] fmts = {"yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss"}; for (String f : fmts) { try { SimpleDateFormat format = new SimpleDateFormat(f); format.setLenient(false); format.setTimeZone(TimeZone.getTimeZone("Asia/Taipei")); return format.parse(s); } catch (Exception ignore) { } } return null; } }