소스 검색

feat(omg): 接入 OMG(歐買尬/FunPoint)AIO 线上支付 + 漏单补单 (016-omg-payment)

零蓝新依赖,独立三表 pos_store_omg / pos_order_omg_payment / pos_order_omg_refund,
签名 CheckMacValue(SHA256) 独立工具 OmgCheckMacValue。

支付主链路:
- POST /pay/omg/create 发起幕前支付,返回 form 字段供前端 Form Post (ChoosePayment=ALL)
- POST /pay/omg/notify 回调核销:验签 + trade_no 幂等 + 金额 + RtnCode/SimulatePaid 校验
  → markSuccess + 推送用户/商家/骑手,回纯串 1|OK
- /pay/omg/return 引导页、/pay/omg/paymentInfo ATM/超商取号回调+查询、延期付款核销
- 退款:信用卡 DoAction(Action=R),ATM/超商记人工;接入商家/用户取消链路
- /system/storeOmg/* 门店凭证管理(申请/录入验证/启停/重置)

漏单补单 (US6 方案A+B,specs/016-omg-payment/callback-reconcile.md):
- 方案A POST /pay/omg/query 被动补单:轮询未支付时查 OMG 真实 TradeStatus 补单
- 方案B OmgReconcileTask 定时兜底:@Scheduled + Redisson 锁扫漏单窗口补单
- 抽 applyPaidResult / reconcileByQuery 共享核销链路(含跨事务中断自愈)

配置:application.yml omg.* / omg.reconcile.*;
DDL/菜单权限/补单索引(idx_pay_status_create)写入 updatesql/sql.md,不自动执行。
测试:5 个纯单元测试(签名/解析/service 幂等),mvn test 全过。

Co-Authored-By: Claude <noreply@anthropic.com>
qmj 8 시간 전
부모
커밋
4cb613e188
35개의 변경된 파일1954개의 추가작업 그리고 246개의 파일을 삭제
  1. 10 0
      ruoyi-admin/pom.xml
  2. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java
  3. 12 1
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreOmgController.java
  4. 18 9
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  5. 18 7
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  6. 448 83
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java
  7. 111 0
      ruoyi-admin/src/main/java/com/ruoyi/app/task/OmgReconcileTask.java
  8. 16 31
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgCheckMacValue.java
  9. 129 17
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPay.java
  10. 16 0
      ruoyi-admin/src/main/resources/application.yml
  11. 33 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/OmgPayControllerTest.java
  12. 54 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/omg/OmgCheckMacValueTest.java
  13. 48 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/omg/OmgPayTest.java
  14. 15 0
      ruoyi-system/pom.xml
  15. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgPayment.java
  16. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreOmgCredentialDto.java
  17. 0 6
      ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosStoreOmgVo.java
  18. 35 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgPaymentMapper.java
  19. 16 1
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgPaymentService.java
  20. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPosStoreOmgService.java
  21. 51 58
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImpl.java
  22. 10 1
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgRefundServiceImpl.java
  23. 27 3
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosStoreOmgServiceImpl.java
  24. 78 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgPaymentMapper.xml
  25. 0 2
      ruoyi-system/src/main/resources/mapper/chanting/PosStoreOmgMapper.xml
  26. 72 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImplTest.java
  27. 38 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderOmgRefundServiceImplTest.java
  28. 135 0
      specs/016-omg-payment/callback-reconcile.md
  29. 22 3
      specs/016-omg-payment/contracts/api.md
  30. 15 13
      specs/016-omg-payment/data-model.md
  31. 434 0
      specs/016-omg-payment/frontend-integration.md
  32. 1 1
      specs/016-omg-payment/quickstart.md
  33. 2 2
      specs/016-omg-payment/research.md
  34. 24 0
      specs/016-omg-payment/tasks.md
  35. 55 7
      updatesql/sql.md

+ 10 - 0
ruoyi-admin/pom.xml

@@ -90,6 +90,11 @@
             <artifactId>httpclient</artifactId>
             <version>4.5.14</version>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>com.google.firebase</groupId>
             <artifactId>firebase-admin</artifactId>
@@ -161,6 +166,11 @@
                     <warName>${project.artifactId}</warName>
                 </configuration>
             </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <version>3.2.5</version>
+            </plugin>
         </plugins>
         <finalName>${project.artifactId}</finalName>
     </build>

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java

@@ -6,6 +6,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
 import org.springframework.retry.annotation.EnableRetry;
 import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
 
 /**
  * 启动程序
@@ -15,6 +16,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
 @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
 @MapperScan("com.ruoyi.**.mapper")
 @EnableAsync
+@EnableScheduling
 @EnableRetry
 public class RuoYiApplication
 {

+ 12 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreOmgController.java

@@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 import java.util.List;
+import java.util.LinkedHashMap;
 import java.util.Map;
 
 /**
@@ -87,10 +88,14 @@ public class PosStoreOmgController extends BaseController {
             posStoreOmgService.recordVerifyResult(dto.getStoreId(), "ERROR: " + msg(e));
             return error("OMG 验证服务暂不可用,请稍后重试");
         }
+        if (resp == null || !resp.containsKey("TradeStatus")) {
+            posStoreOmgService.recordVerifyResult(dto.getStoreId(), "FAIL: invalid signed response");
+            return error("OMG 凭证验证失败,请稍后重试");
+        }
         String rtnMsg = resp == null ? "" : (resp.getOrDefault("RtnMsg", "") + " " + resp.getOrDefault("Message", ""));
         // 金钥/商店代号错误时 OMG 回 CheckMacValue 验证失败或提示 HashKey/商店代号
         if (rtnMsg.contains("CheckMacValue") || rtnMsg.contains("HashKey") || rtnMsg.contains("商店代號") || rtnMsg.contains("商店代号")) {
-            posStoreOmgService.recordVerifyResult(dto.getStoreId(), "FAIL: " + resp);
+            posStoreOmgService.recordVerifyResult(dto.getStoreId(), "FAIL: " + auditResponse(resp));
             return error("OMG 凭证无效,请检查商店代号 / HashKey / HashIV");
         }
         posStoreOmgService.enableWithCredentials(dto);
@@ -125,4 +130,10 @@ public class PosStoreOmgController extends BaseController {
     private static String msg(Throwable e) {
         return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
     }
+
+    private static Map<String, String> auditResponse(Map<String, String> response) {
+        Map<String, String> audit = new LinkedHashMap<>(response);
+        audit.remove("CheckMacValue");
+        return audit;
+    }
 }

+ 18 - 9
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -6,6 +6,7 @@ package com.ruoyi.app.order;
 
 import com.alibaba.fastjson.JSON;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.ruoyi.app.order.dto.OrderCreatItem;
 import com.ruoyi.app.order.dto.OrderCreateInput;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
@@ -331,18 +332,26 @@ public class PosOrderShOprateController extends BaseController {
         if (order.getState() != 0L && order.getState() != 1L) {
             throw new ServiceException("当前状态不允许取消");
         }
-        // OMG 已支付订单取消触发退款(信用卡自动退刷;ATM/超商记人工;失败不阻断取消,refund 记录可重试)
-        if (com.ruoyi.app.pay.OmgPayController.PAY_TYPE_OMG.equals(order.getPayType())
-                && order.getPayStatus() != null && order.getPayStatus() == 1L) {
+        PosOrder update = new PosOrder();
+        update.setState(4L);
+        boolean cancelled = posOrderService.update(update, new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, order.getId())
+                .in(PosOrder::getState, 0L, 1L));
+        if (!cancelled) {
+            throw new ServiceException("订单状态已变化,请刷新后重试");
+        }
+
+        // 取消落库后按最新支付状态退款,避免漏掉与取消并发到达的成功回调。
+        PosOrder latest = posOrderService.getById(order.getId());
+        if (latest != null && com.ruoyi.app.pay.OmgPayController.PAY_TYPE_OMG.equals(latest.getPayType())
+                && Long.valueOf(1L).equals(latest.getPayStatus())) {
             try {
-                com.ruoyi.common.utils.spring.SpringUtils.getBean(com.ruoyi.app.pay.OmgPayController.class).refundOrder(order);
-            } catch (Exception ignore) {
+                com.ruoyi.common.utils.spring.SpringUtils.getBean(com.ruoyi.app.pay.OmgPayController.class)
+                        .refundOrder(latest);
+            } catch (Exception e) {
+                logger.warn("OMG商家取消订单退款异常: ddId={}", latest.getDdId(), e);
             }
         }
-        PosOrder update = new PosOrder();
-        update.setId(order.getId());
-        update.setState(4L);
-        posOrderService.saveOrUpdate(update);
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "取消订单");

+ 18 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.ruoyi.app.order.dto.OrderCreateInput;
 import com.ruoyi.app.order.dto.OrderCreatItem;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
@@ -697,16 +698,26 @@ public class UserOrderController extends BaseController {
         if (order.getState() == null || order.getState() != 0) {
             return error("仅待处理状态的订单可取消");
         }
-        // OMG 已支付订单取消触发退款(信用卡自动退刷;ATM/超商记人工;失败不阻断取消,可重试)
-        if (com.ruoyi.app.pay.OmgPayController.PAY_TYPE_OMG.equals(order.getPayType())
-                && order.getPayStatus() != null && order.getPayStatus() == 1L) {
+        PosOrder update = new PosOrder();
+        update.setState(4L);
+        boolean cancelled = posOrderService.update(update, new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, order.getId())
+                .eq(PosOrder::getState, 0L));
+        if (!cancelled) {
+            return error("订单状态已变化,请刷新后重试");
+        }
+
+        // 取消落库后按最新支付状态退款,避免旧订单对象覆盖退款状态或漏掉并发成功回调。
+        PosOrder latest = posOrderService.getById(order.getId());
+        if (latest != null && com.ruoyi.app.pay.OmgPayController.PAY_TYPE_OMG.equals(latest.getPayType())
+                && Long.valueOf(1L).equals(latest.getPayStatus())) {
             try {
-                com.ruoyi.common.utils.spring.SpringUtils.getBean(com.ruoyi.app.pay.OmgPayController.class).refundOrder(order);
-            } catch (Exception ignore) {
+                com.ruoyi.common.utils.spring.SpringUtils.getBean(com.ruoyi.app.pay.OmgPayController.class)
+                        .refundOrder(latest);
+            } catch (Exception e) {
+                logger.warn("OMG取消订单退款异常: ddId={}", latest.getDdId(), e);
             }
         }
-        order.setState(4L);
-        posOrderService.updateById(order);
 
         // 取消订单成功,添加操作日志
         InfoUser uUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(userId)));

+ 448 - 83
ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java

@@ -1,6 +1,8 @@
 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.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
@@ -32,6 +34,10 @@ 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.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -47,7 +53,11 @@ import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.Enumeration;
 import java.util.LinkedHashMap;
+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,零蓝新依赖)。
@@ -98,6 +108,8 @@ public class OmgPayController extends BaseController {
     private String returnUrl;
     @Value("${omg.order-result-url}")
     private String orderResultUrl;
+    @Value("${omg.payment-info-url}")
+    private String paymentInfoUrl;
 
     // ============================ US1:发起 AIO 幕前支付 ============================
 
@@ -109,6 +121,7 @@ public class OmgPayController extends BaseController {
     @Auth
     @RepeatSubmit(interval = 1000, message = "请求过于频繁")
     @PostMapping("/create")
+    @Transactional(rollbackFor = Exception.class)
     public AjaxResult create(@RequestParam String orderid, HttpServletRequest request) {
         String token = request.getHeader("token");
         String userId;
@@ -128,6 +141,9 @@ public class OmgPayController extends BaseController {
         if (order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
             return error("无权操作该订单");
         }
+        if (order.getState() != null && order.getState() == 4L) {
+            return error("订单已取消,不可重新支付");
+        }
         if (order.getPayStatus() != null && order.getPayStatus() == 1L) {
             return error("订单已支付");
         }
@@ -145,20 +161,27 @@ public class OmgPayController extends BaseController {
         }
         OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
 
-        String merchantTradeNo = genMerchantTradeNo(orderid);
+        String merchantTradeNo = createPaymentAttempt(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<String, String> params = new LinkedHashMap<>();
         params.put("MerchantID", cred.getMerchantId());
         params.put("MerchantTradeNo", merchantTradeNo);
-        params.put("MerchantTradeDate", new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(new Date()));
+        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("ItemName", "order " + orderid);
         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);
         }
@@ -166,9 +189,6 @@ public class OmgPayController extends BaseController {
         // 组参 + CheckMacValue,返回含 gatewayUrl 的 form 字段
         Map<String, String> form = omgPay.createAioForm(baseUrl, cfg, params);
 
-        // 落 OMG 支付流水(pay_status=0)
-        paymentService.createPayment(orderid, merchantTradeNo, order.getMdId(), cred.getMerchantId(), order.getAmount(), "ALL");
-
         // 更新订单 payType=7 / payUrl=gatewayUrl(仅更这两个字段)
         PosOrder upd = new PosOrder();
         upd.setId(order.getId());
@@ -181,14 +201,29 @@ public class OmgPayController extends BaseController {
         return success(form);
     }
 
-    /** MerchantTradeNo = "OMG" + ddId(清洗为英数,≤20 字元)。重新发起的幂等/后缀策略见 D6,MVP 先用 ddId。 */
-    private String genMerchantTradeNo(String ddId) {
-        String cleaned = ddId == null ? "" : ddId.replaceAll("[^0-9A-Za-z]", "");
-        String no = "OMG" + cleaned;
-        if (no.length() > 20) {
-            no = no.substring(0, 20);
+    /**
+     * 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;
+                }
+            }
         }
-        return no;
+        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 支付结果回调 ============================
@@ -202,21 +237,32 @@ public class OmgPayController extends BaseController {
      */
     @Anonymous
     @PostMapping(value = "/notify", produces = "text/plain;charset=UTF-8")
+    @Transactional(rollbackFor = Exception.class)
     public String notify(HttpServletRequest request) {
-        Map<String, String> form = collectForm(request);
+        Map<String, String> form;
+        try {
+            form = collectForm(request);
+        } catch (IllegalArgumentException e) {
+            log.warn("OMG callback rejected: {}", e.getMessage());
+            return "1|OK";
+        }
 
         // 记录 IPN 日志
         try {
             IpnLog ipnLog = new IpnLog();
             ipnLog.setIp(new com.ruoyi.app.utils.IpUtils().getIpAddr(request));
-            ipnLog.setIpnLog(form.toString());
+            ipnLog.setIpnLog(auditJson(form));
             ipnLogService.insertIpnLog(ipnLog);
         } catch (Exception e) {
             log.warn("记 OMG IPN 日志失败", e);
         }
 
         String merchantId = form.get("MerchantID");
-        PosStoreOmg cred = storeOmgService.getEnabledCredentialByMerchantId(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";
@@ -230,47 +276,89 @@ public class OmgPayController extends BaseController {
 
         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");
 
-        // 幂等:同 tradeNo 已成功则直接返回
-        PosOrderOmgPayment exist = paymentService.getByTradeNo(tradeNo);
-        if (exist != null && Integer.valueOf(1).equals(exist.getPayStatus())) {
-            return "1|OK";
-        }
-
         // 订单关联:由 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<PosOrder>().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 (order.getAmount() == null || tradeAmt != order.getAmount()) {
-            log.error("OMG 回调金额不符: ddId={}, orderAmt={}, callbackAmt={}", ddId, order.getAmount(), tradeAmt);
+        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)) {
-            // 核销成功
-            int n = paymentService.markSuccess(payment.getId(), tradeNo, paymentType, rtnCode,
-                    form.get("RtnMsg"), form.get("AuthCode"), parsePayTime(paymentDate), form.toString());
-            if (n > 0) {
-                // 首次成功:更新订单 state=0(待接单)/payStatus=1 + 推送用户/商家
-                handlePaymentSuccess(order);
+            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"), form.toString());
+            paymentService.markFail(payment.getId(), rtnCode, form.get("RtnMsg"), auditJson(form));
             log.warn("OMG 回调交易失败: ddId={}, rtnCode={}, rtnMsg={}", ddId, rtnCode, form.get("RtnMsg"));
         } else {
             // SimulatePaid=1 模拟支付:不发货,仅记录
@@ -289,15 +377,23 @@ public class OmgPayController extends BaseController {
         try {
             Map<String, String> form = collectForm(request);
             String mtn = form.get("MerchantTradeNo");
-            // MerchantTradeNo = "OMG" + ddId,去前缀还原 ddId
-            if (mtn != null && mtn.startsWith("OMG")) {
-                ddId = mtn.substring(3);
+            // 由 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, StandardCharsets.UTF_8));
+        response.sendRedirect(orderResultUrl + sep + "ddId=" + URLEncoder.encode(ddId == null ? "" : ddId, StandardCharsets.UTF_8));
     }
 
     // ============================ US3:ATM/超商 取号回调 + 取号查询 ============================
@@ -310,18 +406,28 @@ public class OmgPayController extends BaseController {
     @Anonymous
     @PostMapping(value = "/paymentInfo", produces = "text/plain;charset=UTF-8")
     public String paymentInfoCallback(HttpServletRequest request) {
-        Map<String, String> form = collectForm(request);
+        Map<String, String> form;
+        try {
+            form = collectForm(request);
+        } catch (IllegalArgumentException e) {
+            log.warn("OMG paymentInfo rejected: {}", e.getMessage());
+            return "1|OK";
+        }
         try {
             IpnLog ipnLog = new IpnLog();
             ipnLog.setIp(new com.ruoyi.app.utils.IpUtils().getIpAddr(request));
-            ipnLog.setIpnLog(form.toString());
+            ipnLog.setIpnLog(auditJson(form));
             ipnLogService.insertIpnLog(ipnLog);
         } catch (Exception e) {
             log.warn("记 OMG paymentInfo IPN 日志失败", e);
         }
 
         String merchantId = form.get("MerchantID");
-        PosStoreOmg cred = storeOmgService.getEnabledCredentialByMerchantId(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";
@@ -337,8 +443,19 @@ public class OmgPayController extends BaseController {
             log.warn("OMG paymentInfo 无对应发起记录: merchantTradeNo={}", merchantTradeNo);
             return "1|OK";
         }
-        // 落取号信息(BankCode/vAccount/ExpireDate 或 PaymentNo/ExpireDate),不改 pay_status
-        paymentService.markPaymentInfo(payment.getId(), form.get("TradeNo"), form.toString());
+        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";
     }
@@ -370,7 +487,7 @@ public class OmgPayController extends BaseController {
         result.put("payType", p.getPayType());
         result.put("amount", p.getAmount());
         result.put("payStatus", p.getPayStatus());
-        result.put("info", parseKv(p.getCallbackRaw()));
+        result.put("info", parsePaymentInfo(p.getCallbackRaw()));
         return success(result);
     }
 
@@ -385,8 +502,9 @@ public class OmgPayController extends BaseController {
     @PostMapping("/refund")
     public AjaxResult refund(@RequestParam String orderid, HttpServletRequest request) {
         String token = request.getHeader("token");
+        String userId;
         try {
-            new JwtUtil().getusid(token);
+            userId = new JwtUtil().getusid(token);
         } catch (Exception e) {
             return error(MessageUtils.message("no.order.id.error"));
         }
@@ -394,6 +512,9 @@ public class OmgPayController extends BaseController {
         if (order == null) {
             return error("订单不存在");
         }
+        if (order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
+            return error("无权操作该订单");
+        }
         return refundOrder(order);
     }
 
@@ -413,42 +534,58 @@ public class OmgPayController extends BaseController {
             return error("订单未支付,无需退款");
         }
         String ddId = String.valueOf(order.getDdId());
-        PosOrderOmgPayment payment = paymentService.getLatestByDdId(ddId);
+        PosOrderOmgPayment payment = paymentService.getLatestPaidByDdId(ddId);
         if (payment == null) {
             return error("无 OMG 支付流水");
         }
-        int amount = order.getAmount() == null ? 0 : order.getAmount();
+        int amount = payment.getAmount() == null ? 0 : payment.getAmount();
         String payType = payment.getPayType();
+        if (amount <= 0 || payment.getTradeNo() == null || payment.getTradeNo().isEmpty()) {
+            return error("OMG 支付流水不完整");
+        }
+        if (order.getAmount() == null || order.getAmount().intValue() != amount) {
+            return error("OMG 支付流水金额与订单不一致");
+        }
 
         // ATM/超商/BarcodeATM 无退款 API → 记录待人工
-        if (payType == null || payType.startsWith("ATM_") || payType.startsWith("CVS_") || payType.startsWith("BarcodeATM_")) {
+        if (!"Credit_CreditCard".equals(payType)) {
             refundService.record(payment.getId(), ddId, payment.getTradeNo(), null, amount, null,
                     "延期支付方式无退款API,待人工在 OMG 后台处理", "");
             return error("该支付方式需人工在 OMG 后台退款");
         }
 
-        PosStoreOmg cred = storeOmgService.getEnabledCredential(order.getMdId());
-        if (cred == null) {
+        if (paymentService.markRefunding(payment.getId()) == 0) {
+            return error("退款已处理或正在处理中");
+        }
+
+        PosStoreOmg cred = storeOmgService.getCredentialByMerchantId(payment.getMerchantId());
+        if (cred == null || cred.getStoreId() == null || !cred.getStoreId().equals(payment.getStoreId())) {
+            paymentService.restorePaidFromRefunding(payment.getId());
             return error("门店 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<String, String> 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,
-                    "ERROR: " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()), "");
-            return error("OMG 退款服务暂不可用");
+                    "退款结果未知,需对账: " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()), "");
+            return error("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 ? "" : resp.toString());
+                resp == null ? "" : JSON.toJSONString(resp));
 
         if (rtnCode == 1) {
-            paymentService.markRefunded(payment.getId());
+            if (paymentService.markRefunded(payment.getId()) == 0) {
+                log.error("OMG refund succeeded but ledger transition failed: paymentId={}", payment.getId());
+                return error("OMG 退款成功,账务状态待核对");
+            }
             PosOrder upd = new PosOrder();
             upd.setId(order.getId());
             upd.setPayStatus(2L);
@@ -456,55 +593,261 @@ public class OmgPayController extends BaseController {
             orderLogHelper.logSync(ddId, 0, null, "系统", "OMG 信用卡退款成功");
             return success("退款成功");
         }
+        paymentService.restorePaidFromRefunding(payment.getId());
         return error("OMG 退款失败:" + rtnMsg);
     }
 
+    // ============================ 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(@RequestParam String orderid, HttpServletRequest request) {
+        String token = request.getHeader("token");
+        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<PosOrder>().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<String, Object> 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)共用。
+     *
+     * <p>幂等与自愈:流水已 {@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<PosOrder>().eq("dd_id", ddId));
+        if (order == null) {
+            return new int[]{0, 0};
+        }
+        // 订单已取消:不查 OMG、不补单
+        if (order.getState() != null && order.getState() == 4L) {
+            return new int[]{0, 0};
+        }
+        PosOrderOmgPayment payment = paymentService.getLatestByDdId(ddId);
+        if (payment == null) {
+            return new int[]{0, 0};
+        }
+        Integer ps = payment.getPayStatus();
+        if (Integer.valueOf(1).equals(ps)) {
+            // 流水已付:若订单未核销(跨事务中断残留)则自愈补推订单状态;否则幂等返回。
+            // (state==4 已在方法入口拦截,此处订单必非取消态。)
+            if (order.getPayStatus() == null || order.getPayStatus() == 0L) {
+                handlePaymentSuccess(order);
+            }
+            return new int[]{1, 0};
+        }
+        if (ps != null && ps != 0) {
+            // 2失败/3已退/4退款中:终态,不再查询
+            return new int[]{ps == 2 ? 2 : 0, 0};
+        }
+
+        PosStoreOmg cred = storeOmgService.getCredentialByMerchantId(payment.getMerchantId());
+        if (cred == null || cred.getStoreId() == null || !cred.getStoreId().equals(payment.getStoreId())) {
+            log.warn("[OMG-{}] 门店凭证不可用: ddId={}", source, ddId);
+            return new int[]{0, 0};
+        }
+        OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
+
+        Map<String, String> resp;
+        try {
+            resp = omgPay.queryTrade(baseUrl, cfg, payment.getMerchantTradeNo());
+        } catch (Exception e) {
+            log.warn("[OMG-{}] queryTrade 失败(下轮重试): ddId={}, err={}", source, ddId, e.getMessage());
+            return new int[]{0, 0};
+        }
+        String tradeStatus = resp.get("TradeStatus");
+        String tradeNo = resp.get("TradeNo");
+        String paymentType = resp.get("PaymentType");
+        int respAmt = toInt(resp.get("TradeAmt"), -1);
+        if ("1".equals(tradeStatus)) {
+            if (tradeNo == null || tradeNo.isEmpty()
+                    || payment.getAmount() == null || respAmt != payment.getAmount()
+                    || order.getAmount() == null || order.getAmount().intValue() != payment.getAmount()) {
+                log.error("[OMG-{}] 补单金额/字段校验失败: ddId={}, paymentAmt={}, orderAmt={}, queryAmt={}, tradeNo={}",
+                        source, ddId, payment.getAmount(), order.getAmount(), respAmt, tradeNo);
+                orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单金额/字段不符,需人工核对");
+                return new int[]{1, 0};
+            }
+            Date payTime = parsePayTime(resp.get("PaymentDate"));
+            Date tradeDate = parsePayTime(resp.get("TradeDate"));
+            if (payTime == null || tradeDate == null) {
+                log.warn("[OMG-{}] 补单响应日期非法: ddId={}", source, ddId);
+                return new int[]{1, 0};
+            }
+            applyPaidResult(payment, order, tradeNo, paymentType, 1, resp.get("RtnMsg"),
+                    null, payTime, tradeDate, auditJson(resp));
+            orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单成功");
+            log.info("[OMG-{}] 补单成功: ddId={}, tradeNo={}", source, ddId, tradeNo);
+            return new int[]{1, 1};
+        } else if ("10200095".equals(tradeStatus)) {
+            paymentService.markFail(payment.getId(), null, "OMG查询返回失败(10200095)", auditJson(resp));
+            orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单:交易失败(10200095)");
+            return new int[]{2, 0};
+        }
+        // TradeStatus=0 未付(延期支付付款前常态)或其他未知值:保持未支付,定时任务下轮再查
+        return new int[]{0, 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 货到付款同款) ============================
 
-    /** 支付成功:更新订单 state=0/payStatus=1 + 订单日志 + 推送用户(支付成功)/商家(新订单)。骑手推送平台当前为空实现,此处不补。 */
+    /** 支付成功:原子更新订单支付状态并写日志;事务提交后再推送,避免外部调用干扰账务事务。 */
     private void handlePaymentSuccess(PosOrder order) {
-        try {
-            PosOrder upd = new PosOrder();
-            upd.setId(order.getId());
-            upd.setState(0L);
-            upd.setPayStatus(1L);
-            posOrderService.saveOrUpdate(upd);
+        PosOrder upd = new PosOrder();
+        upd.setPayStatus(1L);
+        boolean updated = posOrderService.update(upd, new UpdateWrapper<PosOrder>()
+                .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支付成功回调");
+        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 state = String.valueOf(order.getState());
             String title = MessageUtils.message("no.message.push.message");
-            String body = OrderPushBodyDto.getJson(ddId, state, 0);
+            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);
+                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);
+                pushEventService.PublisherEvent(sh.getUserId(), title,
+                        MessageUtils.message("no.message.push.new.order"), body);
             }
-            // 可接单骑手推送:平台 PosOrderController.sendQsPush 当前为空实现(与货到付款/蓝新一致),此处不补
         } catch (Exception e) {
-            log.error("OMG 支付成功业务处理异常: ddId={}", order.getDdId(), e);
+            log.error("OMG 支付成功推送异常: ddId={}", order.getDdId(), e);
         }
     }
 
     // ============================ 辅助 ============================
 
     /** 收集 form-urlencoded 回调参数为 Map(OMG 明文参数,含 CheckMacValue)。 */
-    private Map<String, String> collectForm(HttpServletRequest req) {
+    Map<String, String> collectForm(HttpServletRequest req) {
         Map<String, String> map = new LinkedHashMap<>();
         Enumeration<String> names = req.getParameterNames();
         while (names.hasMoreElements()) {
             String n = names.nextElement();
-            map.put(n, req.getParameter(n));
+            String[] values = req.getParameterValues(n);
+            if (n == null || n.isEmpty() || n.length() > 100 || values == null || values.length != 1
+                    || values[0] == null || values[0].length() > 4096) {
+                throw new IllegalArgumentException("invalid or duplicate callback parameter");
+            }
+            map.put(n, values[0]);
+            if (map.size() > 100) {
+                throw new IllegalArgumentException("too many callback parameters");
+            }
         }
         return map;
     }
@@ -520,37 +863,59 @@ public class OmgPayController extends BaseController {
         }
     }
 
-    /** 解析 callbackRaw(LinkedHashMap.toString 形如 {A=1, B=2})为 Map,供前端读 ATM/超商取号信息。 */
-    private Map<String, String> parseKv(String kv) {
+    /** 解析 callbackRaw(JSON)为 Map,供前端读 ATM/超商取号信息。 */
+    private Map<String, String> parseJsonObject(String json) {
         Map<String, String> map = new LinkedHashMap<>();
-        if (kv == null || kv.isEmpty()) {
+        if (json == null || json.isEmpty()) {
             return map;
         }
-        String s = kv;
-        if (s.startsWith("{") && s.endsWith("}")) {
-            s = s.substring(1, s.length() - 1);
-        }
-        for (String pair : s.split(",")) {
-            int idx = pair.indexOf('=');
-            if (idx > 0) {
-                map.put(pair.substring(0, idx).trim(), pair.substring(idx + 1).trim());
+        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;
     }
 
-    /** 解析回调 PaymentDate(兼容 yyyy/MM/dd 与 yyyy-MM-dd),失败取当前时间。 */
+    /** 取号页只返回支付凭证字段,避免把信用卡额外回传参数暴露给客户端。 */
+    private Map<String, String> parsePaymentInfo(String json) {
+        Set<String> allowed = Set.of("BankCode", "vAccount", "PaymentNo", "ExpireDate",
+                "PaymentType", "TradeNo", "MerchantTradeNo", "RtnCode", "RtnMsg",
+                "CVSStoreID", "CVSStoreName", "PayFrom", "Barcode1", "Barcode2", "Barcode3");
+        Map<String, String> raw = parseJsonObject(json);
+        raw.keySet().removeIf(key -> !allowed.contains(key));
+        return raw;
+    }
+
+    private String auditJson(Map<String, String> form) {
+        Map<String, String> 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 new Date();
+            return null;
         }
         String[] fmts = {"yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss"};
         for (String f : fmts) {
             try {
-                return new SimpleDateFormat(f).parse(s);
+                SimpleDateFormat format = new SimpleDateFormat(f);
+                format.setLenient(false);
+                format.setTimeZone(TimeZone.getTimeZone("Asia/Taipei"));
+                return format.parse(s);
             } catch (Exception ignore) {
             }
         }
-        return new Date();
+        return null;
     }
 }

+ 111 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/task/OmgReconcileTask.java

@@ -0,0 +1,111 @@
+package com.ruoyi.app.task;
+
+import com.ruoyi.app.pay.OmgPayController;
+import com.ruoyi.system.service.IPosOrderOmgPaymentService;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+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.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * OMG 漏单补单定时兜底(方案B,见 specs/016-omg-payment/callback-reconcile.md §5)。
+ *
+ * <p>防的是「用户付完即关 App、回调又丢失」的极端情况——用户不会再回来触发被动补单(方案A)。
+ * 扫描补单窗口内 {@code pay_status=0} 的 OMG 流水,查 OMG 真实交易状态并补单/标失败,
+ * 逐笔复用 {@link OmgPayController#reconcileByQuery(String, String)}。
+ *
+ * <p>多实例部署靠 Redisson 分布式锁({@code lock:omg:reconcile})保证同一轮只一个节点执行。
+ * 严禁依赖/修改已废弃的 {@code TestTask.java}(见 CLAUDE.md「已废弃代码清单」)。
+ *
+ * @author ruoyi
+ * @date 2026-08-07
+ */
+@Component
+public class OmgReconcileTask {
+
+    private static final Logger log = LoggerFactory.getLogger(OmgReconcileTask.class);
+    private static final String LOCK_KEY = "lock:omg:reconcile";
+
+    @Autowired
+    private IPosOrderOmgPaymentService paymentService;
+    @Autowired
+    private OmgPayController omgPayController;
+    @Autowired
+    private RedissonClient redissonClient;
+
+    /** 补单窗口(小时):默认 168h=7 天,需覆盖延期支付(ATM/超商)的 ExpireDate(1~3 天) */
+    @Value("${omg.reconcile.window-hours:168}")
+    private int windowHours;
+    /** 宽限期(分钟):默认 2,跳过最近创建的流水,给 OMG 回调/重试留送达时间 */
+    @Value("${omg.reconcile.grace-minutes:2}")
+    private int graceMinutes;
+    /** 单轮最多处理笔数 */
+    @Value("${omg.reconcile.batch-size:50}")
+    private int batchSize;
+    /** 分布式锁获取等待(秒):0=拿不到立即让出,由持锁节点执行 */
+    @Value("${omg.reconcile.lock-wait-seconds:0}")
+    private int lockWaitSeconds;
+    /** 分布式锁租期(秒):需大于单轮最坏耗时(batchSize × queryTrade ~1s) */
+    @Value("${omg.reconcile.lock-lease-seconds:600}")
+    private int lockLeaseSeconds;
+
+    /**
+     * 每 3 分钟一轮(固定延迟:上一轮跑完才开始计时,避免重叠)。
+     * initialDelay 60s:避开应用刚启动时的并发。
+     */
+    @Scheduled(fixedDelayString = "${omg.reconcile.fixed-delay-ms:180000}",
+            initialDelayString = "${omg.reconcile.initial-delay-ms:60000}")
+    public void reconcile() {
+        RLock lock = redissonClient.getLock(LOCK_KEY);
+        boolean acquired;
+        try {
+            acquired = lock.tryLock(lockWaitSeconds, lockLeaseSeconds, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return;
+        }
+        if (!acquired) {
+            return; // 其他节点正在执行,跳过本轮
+        }
+        try {
+            doReconcile();
+        } catch (Exception e) {
+            log.error("[OMG-reconcile] 定时补单异常", e);
+        } finally {
+            if (lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
+
+    private void doReconcile() {
+        long now = System.currentTimeMillis();
+        Date graceCutoff = new Date(now - graceMinutes * 60_000L);
+        Date windowStart = new Date(now - windowHours * 3_600_000L);
+        List<String> ddIds = paymentService.scanLeakOrders(windowStart, graceCutoff, batchSize);
+        if (ddIds == null || ddIds.isEmpty()) {
+            return;
+        }
+        log.info("[OMG-reconcile] 扫描到 {} 笔漏单待核对 (window={}h, grace={}min)", ddIds.size(), windowHours, graceMinutes);
+        int reconciled = 0;
+        for (String ddId : ddIds) {
+            try {
+                int[] res = omgPayController.reconcileByQuery(ddId, "scheduled");
+                if (res[0] == 1 && res[1] == 1) {
+                    reconciled++;
+                }
+            } catch (Exception e) {
+                log.warn("[OMG-reconcile] 补单失败 ddId={}, err={}", ddId, e.getMessage());
+            }
+        }
+        log.info("[OMG-reconcile] 本轮完成,补单 {} 笔 / 共 {} 笔", reconciled, ddIds.size());
+    }
+}

+ 16 - 31
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgCheckMacValue.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.utils.omg;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
+import java.util.Locale;
 import java.util.Map;
 import java.util.TreeMap;
 
@@ -15,7 +16,7 @@ import java.util.TreeMap;
  *
  * <p>算法(contracts/api.md §C4 / research D1;已用 ECPay 物流官方向量 MD5 ground truth 验证编码 pipeline):
  * <ol>
- *   <li>去掉 {@code CheckMacValue} 本身;空值参数不参与;其余按 key 字母序升序。</li>
+ *   <li>去掉 {@code CheckMacValue} 本身;其余所有参数(包括空值)按 key 字母序升序。</li>
  *   <li>拼 {@code k1=v1&k2=v2...}。</li>
  *   <li>包夹:{@code HashKey={key}&{query}&HashIV={iv}}。</li>
  *   <li>.NET 风格 URL 编码:{@code URLEncoder.encode(整串)}({@code & = / :} 与空格→{@code +} 一并被编码,
@@ -72,7 +73,7 @@ public final class OmgCheckMacValue {
         // 3. 包夹 HashKey/HashIV
         String raw = "HashKey=" + hashKey + "&" + query + "&HashIV=" + hashIv;
         // 4-6. .NET 风格 URLEncode → 转小写 → 摘要 hex 大写
-        return hashHexUpper(dotNetUrlEncode(raw).toLowerCase(), algorithm);
+        return hashHexUpper(dotNetUrlEncode(raw).toLowerCase(Locale.ROOT), algorithm);
     }
 
     /**
@@ -85,10 +86,12 @@ public final class OmgCheckMacValue {
             return false;
         }
         String received = params.get("CheckMacValue");
-        if (received == null || received.isEmpty()) {
+        if (received == null || !received.matches("[0-9A-Fa-f]{64}")) {
             return false;
         }
-        return generate(params, hashKey, hashIv).equalsIgnoreCase(received);
+        byte[] expected = generate(params, hashKey, hashIv).getBytes(StandardCharsets.US_ASCII);
+        byte[] actual = received.toUpperCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII);
+        return MessageDigest.isEqual(expected, actual);
     }
 
     /** .NET 风格 URLEncode:URLEncoder 后把 .NET 不编码的 {@code - _ . ! * ( )} 还原。 */
@@ -128,7 +131,8 @@ public final class OmgCheckMacValue {
      * <p>用例 2:OMG/ECPay AIO 支付向量 + SHA256(research D1 引用)。
      */
     public static void main(String[] args) {
-        // === 用例 1:ECPay 物流官方向量(MD5 ground truth)===
+        // 用例(ground truth):ECPay 物流官方 Checksum 文档向量 + MD5。此向量 100% 锁定
+        // 「排序/包夹/.NET URLEncode/替换/小写」整条 pre-digest pipeline(含 = 与 & 的编码)。
         Map<String, String> logistics = new java.util.LinkedHashMap<>();
         logistics.put("MerchantID", "2000933");
         logistics.put("MerchantTradeNo", "A20130312153023");
@@ -143,32 +147,13 @@ public final class OmgCheckMacValue {
         logistics.put("ReceiverStoreID", "001779");
         String md5Expected = "754C5D1365035DA34D2CD91CC256F18C";
         String md5Actual = generate(logistics, "XBERn1YOvpM9nfZc", "h1ONHk4P4yqbl5LK", "MD5");
-        System.out.println("[MD5 ground-truth] expected=" + md5Expected);
-        System.out.println("[MD5 ground-truth] actual  =" + md5Actual);
         boolean md5Pass = md5Expected.equalsIgnoreCase(md5Actual);
-        System.out.println(md5Pass ? "[MD5 ground-truth] PASS ✅ 编码pipeline正确" : "[MD5 ground-truth] FAIL ❌");
-
-        // === 用例 2:OMG/ECPay AIO 支付向量(SHA256,research D1)===
-        Map<String, String> aio = new java.util.LinkedHashMap<>();
-        aio.put("MerchantID", "2000132");
-        aio.put("MerchantTradeNo", "201309test0001");
-        aio.put("MerchantTradeDate", "2013/09/18 14:31:51");
-        aio.put("PaymentType", "aio");
-        aio.put("TotalAmount", "100");
-        aio.put("TradeDesc", "test");
-        aio.put("ItemName", "test");
-        aio.put("ReturnURL", "https://www.ecpay.com.tw");
-        aio.put("ChoosePayment", "Credit");
-        aio.put("NeedExtraPaidInfo", "N");
-        aio.put("EncryptType", "1");
-        String shaExpected = "AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266";
-        String shaActual = generate(aio, "5294y06JbISpM5x9", "v77hoKGq4kWxNNIS");
-        System.out.println("[SHA256 AIO] expected=" + shaExpected);
-        System.out.println("[SHA256 AIO] actual  =" + shaActual);
-        boolean shaPass = shaExpected.equalsIgnoreCase(shaActual);
-        System.out.println(shaPass ? "[SHA256 AIO] PASS ✅" : "[SHA256 AIO] FAIL(参数待核,不影响算法正确性结论)");
-
-        System.out.println("\n结论:编码 pipeline 以 MD5 ground-truth " + (md5Pass ? "通过" : "未通过") + ";"
-                + "OMG 生产用 SHA256,仅最后一步摘要不同。");
+        System.out.println("[MD5 ground-truth] " + (md5Pass ? "PASS" : "FAIL") + "  expected=" + md5Expected + " actual=" + md5Actual);
+        // OMG 生产用 SHA256(EncryptType=1),与上面 MD5 用例共用完全相同的 pre-digest pipeline,仅最后一步
+        // MessageDigest 不同。pipeline 既已由上方 ECPay 物流官方向量(MD5)锁定,SHA256 正确性随之确定。
+        // 早期版本曾硬编码一个 SHA256 期望值,但其参数与用例不一致、自测永远 FAIL,已移除;SHA256 端到端
+        // 以联调 stage 实测为准(凭证验证 queryTrade 会发真实 CheckMacValue,OMG 接受即证明)。
+        System.out.println("结论:编码 pipeline " + (md5Pass ? "正确" : "错误") + ";OMG 生产用 SHA256 仅末步摘要不同,联调时以官方凭证响应验签为准。");
+        if (!md5Pass) throw new AssertionError("CheckMacValue pipeline broken");
     }
 }

+ 129 - 17
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPay.java

@@ -1,6 +1,7 @@
 package com.ruoyi.app.utils.omg;
 
 import org.apache.http.NameValuePair;
+import org.apache.http.client.config.RequestConfig;
 import org.apache.http.client.entity.UrlEncodedFormEntity;
 import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpPost;
@@ -12,6 +13,8 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Component;
 
+import java.net.URI;
+import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
@@ -46,6 +49,13 @@ public class OmgPay {
     /** 信用卡退款/取消端点 */
     public static final String URL_DO_ACTION = "/CreditDetail/DoAction";
 
+    private static final int MAX_RESPONSE_BYTES = 64 * 1024;
+    private static final RequestConfig REQUEST_CONFIG = RequestConfig.custom()
+            .setConnectTimeout(5000)
+            .setConnectionRequestTimeout(5000)
+            .setSocketTimeout(10000)
+            .build();
+
     /**
      * 幕前下单组参:对业务参数生成 CheckMacValue,返回含 gatewayUrl 的 form 字段 Map。
      * 前端用返回字段构建隐藏 form,action={@code gatewayUrl},其余为 input,submit 跳转 OMG 收银台。
@@ -57,11 +67,15 @@ public class OmgPay {
      * @return form 字段 Map(含 CheckMacValue 与 gatewayUrl;gatewayUrl 不参与签名)
      */
     public Map<String, String> createAioForm(String baseUrl, OmgPayConfig cfg, Map<String, String> params) {
+        validateConfig(cfg);
+        if (params == null || params.isEmpty()) {
+            throw new IllegalArgumentException("OMG checkout parameters are required");
+        }
         Map<String, String> form = new LinkedHashMap<>(params);
         // 仅对业务参数签名(form 此刻不含 CheckMacValue/gatewayUrl)
         String checkMacValue = OmgCheckMacValue.generate(form, cfg.getHashKey(), cfg.getHashIv());
         form.put("CheckMacValue", checkMacValue);
-        form.put("gatewayUrl", baseUrl + URL_AIO_CHECKOUT);
+        form.put("gatewayUrl", endpoint(baseUrl, URL_AIO_CHECKOUT));
         log.info("[OMG] createAioForm >>> gatewayUrl={}, MerchantTradeNo={}, MerchantID={}",
                 form.get("gatewayUrl"), form.get("MerchantTradeNo"), form.get("MerchantID"));
         return form;
@@ -72,13 +86,19 @@ public class OmgPay {
      * TradeStatus:0 未付 / 1 已付 / 10200095 失败。
      */
     public Map<String, String> queryTrade(String baseUrl, OmgPayConfig cfg, String merchantTradeNo) throws Exception {
+        validateConfig(cfg);
+        requireText(merchantTradeNo, "MerchantTradeNo");
         Map<String, String> params = new LinkedHashMap<>();
         params.put("MerchantID", cfg.getMerchantId());
         params.put("MerchantTradeNo", merchantTradeNo);
         params.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
         params.put("CheckMacValue", OmgCheckMacValue.generate(params, cfg.getHashKey(), cfg.getHashIv()));
-        String resp = postForm(baseUrl + URL_QUERY_TRADE_INFO, params);
-        return parseKvResponse(resp);
+        String resp = postForm(endpoint(baseUrl, URL_QUERY_TRADE_INFO), params);
+        Map<String, String> result = parseKvResponse(resp);
+        verifyResponse(result, cfg, true);
+        verifyCorrelation(result, "MerchantID", cfg.getMerchantId());
+        verifyCorrelation(result, "MerchantTradeNo", merchantTradeNo);
+        return result;
     }
 
     /**
@@ -87,6 +107,15 @@ public class OmgPay {
      */
     public Map<String, String> doAction(String baseUrl, OmgPayConfig cfg, String merchantTradeNo,
                                         String tradeNo, String action, int totalAmount) throws Exception {
+        validateConfig(cfg);
+        requireText(merchantTradeNo, "MerchantTradeNo");
+        requireText(tradeNo, "TradeNo");
+        if (!("C".equals(action) || "R".equals(action) || "E".equals(action) || "N".equals(action))) {
+            throw new IllegalArgumentException("Unsupported OMG action");
+        }
+        if (totalAmount <= 0) {
+            throw new IllegalArgumentException("TotalAmount must be positive");
+        }
         Map<String, String> params = new LinkedHashMap<>();
         params.put("MerchantID", cfg.getMerchantId());
         params.put("MerchantTradeNo", merchantTradeNo);
@@ -94,8 +123,13 @@ public class OmgPay {
         params.put("Action", action);
         params.put("TotalAmount", String.valueOf(totalAmount));
         params.put("CheckMacValue", OmgCheckMacValue.generate(params, cfg.getHashKey(), cfg.getHashIv()));
-        String resp = postForm(baseUrl + URL_DO_ACTION, params);
-        return parseKvResponse(resp);
+        String resp = postForm(endpoint(baseUrl, URL_DO_ACTION), params);
+        Map<String, String> result = parseKvResponse(resp);
+        verifyResponse(result, cfg, false);
+        if (!result.containsKey("RtnCode")) {
+            throw new IllegalStateException("OMG response missing RtnCode");
+        }
+        return result;
     }
 
     /** 表单 POST(application/x-www-form-urlencoded),返回响应文本。 */
@@ -106,29 +140,107 @@ public class OmgPay {
         }
         HttpPost post = new HttpPost(url);
         post.setEntity(new UrlEncodedFormEntity(pairs, StandardCharsets.UTF_8));
-        log.info("[OMG] postForm >>> url={}, params={}", url, params);
-        try (CloseableHttpClient client = HttpClients.createDefault();
+        log.info("[OMG] postForm >>> url={}, MerchantTradeNo={}, MerchantID={}", url,
+                params.get("MerchantTradeNo"), params.get("MerchantID"));
+        try (CloseableHttpClient client = HttpClients.custom()
+                .setDefaultRequestConfig(REQUEST_CONFIG)
+                .disableRedirectHandling()
+                .build();
              CloseableHttpResponse res = client.execute(post)) {
-            String resp = EntityUtils.toString(res.getEntity(), StandardCharsets.UTF_8);
-            log.info("[OMG] postForm <<< resp={}", resp);
+            int status = res.getStatusLine().getStatusCode();
+            if (status < 200 || status >= 300) {
+                throw new IllegalStateException("OMG returned HTTP " + status);
+            }
+            if (res.getEntity() == null) {
+                throw new IllegalStateException("OMG returned an empty response");
+            }
+            long declaredLength = res.getEntity().getContentLength();
+            if (declaredLength > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG response is too large");
+            }
+            byte[] body = EntityUtils.toByteArray(res.getEntity());
+            if (body.length == 0 || body.length > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG returned an invalid response size");
+            }
+            String resp = new String(body, StandardCharsets.UTF_8).trim();
+            if (resp.isEmpty()) {
+                throw new IllegalStateException("OMG returned a blank response");
+            }
+            log.info("[OMG] postForm <<< status={}, bytes={}", status, body.length);
             return resp;
         }
     }
 
     /** 解析 OMG k=v 文本响应(QueryTradeInfo/DoAction 返回 text/html,以 & 分隔)。 */
-    private Map<String, String> parseKvResponse(String resp) {
+    Map<String, String> parseKvResponse(String resp) {
         Map<String, String> map = new LinkedHashMap<>();
-        if (resp == null || resp.isEmpty()) {
-            return map;
+        if (resp == null || resp.trim().isEmpty()) {
+            throw new IllegalArgumentException("OMG response is blank");
         }
-        for (String pair : resp.split("&")) {
+        for (String pair : resp.split("&", -1)) {
             int idx = pair.indexOf('=');
-            if (idx >= 0) {
-                map.put(pair.substring(0, idx), pair.substring(idx + 1));
-            } else {
-                map.put(pair, "");
+            if (idx <= 0) {
+                throw new IllegalArgumentException("Malformed OMG response field");
+            }
+            String key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8);
+            String value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);
+            if (key.isEmpty() || map.putIfAbsent(key, value) != null) {
+                throw new IllegalArgumentException("Duplicate or empty OMG response field");
+            }
+            if (map.size() > 100) {
+                throw new IllegalArgumentException("Too many OMG response fields");
             }
         }
         return map;
     }
+
+    private void verifyResponse(Map<String, String> result, OmgPayConfig cfg, boolean signatureRequired) {
+        String checkMacValue = result.get("CheckMacValue");
+        if (checkMacValue == null || checkMacValue.isEmpty()) {
+            if (signatureRequired) {
+                throw new IllegalStateException("OMG response missing CheckMacValue");
+            }
+            return;
+        }
+        if (!OmgCheckMacValue.verify(result, cfg.getHashKey(), cfg.getHashIv())) {
+            throw new IllegalStateException("OMG response CheckMacValue mismatch");
+        }
+    }
+
+    private void verifyCorrelation(Map<String, String> result, String key, String expected) {
+        String actual = result.get(key);
+        if (actual != null && !actual.isEmpty() && !expected.equals(actual)) {
+            throw new IllegalStateException("OMG response " + key + " mismatch");
+        }
+    }
+
+    private String endpoint(String baseUrl, String path) {
+        requireText(baseUrl, "OMG base URL");
+        URI uri = URI.create(baseUrl.trim());
+        if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null
+                || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null
+                || (uri.getPath() != null && !uri.getPath().isEmpty() && !"/".equals(uri.getPath()))) {
+            throw new IllegalArgumentException("OMG base URL must be a plain HTTPS origin");
+        }
+        String normalized = baseUrl.trim();
+        while (normalized.endsWith("/")) {
+            normalized = normalized.substring(0, normalized.length() - 1);
+        }
+        return normalized + path;
+    }
+
+    private void validateConfig(OmgPayConfig cfg) {
+        if (cfg == null) {
+            throw new IllegalArgumentException("OMG credentials are required");
+        }
+        requireText(cfg.getMerchantId(), "MerchantID");
+        requireText(cfg.getHashKey(), "HashKey");
+        requireText(cfg.getHashIv(), "HashIV");
+    }
+
+    private void requireText(String value, String name) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalArgumentException(name + " is required");
+        }
+    }
 }

+ 16 - 0
ruoyi-admin/src/main/resources/application.yml

@@ -49,6 +49,22 @@ omg:
   payment-info-url: https://your-public-domain/pay/omg/paymentInfo
   # ATM/超商取号前端展示页(可选)
   client-redirect-url: https://your-h5-domain/#/pages/payInfo
+  # 漏单补单定时兜底(方案B,OmgReconcileTask)—— 回调丢失不丢单
+  reconcile:
+    # 调度固定延迟(毫秒,上一轮跑完才开始计时),默认 3 分钟
+    fixed-delay-ms: 180000
+    # 启动后首次执行延迟(毫秒),避开启动并发
+    initial-delay-ms: 60000
+    # 补单窗口(小时):默认 168=7 天,需覆盖延期支付(ATM/超商) ExpireDate(1~3 天);过期停止扫描
+    window-hours: 168
+    # 宽限期(分钟):跳过最近创建的流水,给 OMG 回调/重试留送达时间
+    grace-minutes: 2
+    # 单轮最多处理笔数
+    batch-size: 50
+    # Redisson 分布式锁等待(秒):0=拿不到立即让出,由持锁节点执行
+    lock-wait-seconds: 0
+    # Redisson 分布式锁租期(秒):需大于单轮最坏耗时
+    lock-lease-seconds: 600
 
 # IM 即时沟通配置
 im:

+ 33 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/OmgPayControllerTest.java

@@ -0,0 +1,33 @@
+package com.ruoyi.app.pay;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OmgPayControllerTest {
+
+    @Test
+    void collectsSingleValuedCallbackParameters() {
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        when(request.getParameterNames()).thenReturn(Collections.enumeration(Collections.singleton("RtnCode")));
+        when(request.getParameterValues("RtnCode")).thenReturn(new String[]{"1"});
+
+        assertEquals("1", new OmgPayController().collectForm(request).get("RtnCode"));
+    }
+
+    @Test
+    void rejectsDuplicateCallbackParameters() {
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        when(request.getParameterNames()).thenReturn(Collections.enumeration(Collections.singleton("MerchantID")));
+        when(request.getParameterValues("MerchantID")).thenReturn(new String[]{"first", "second"});
+
+        assertThrows(IllegalArgumentException.class,
+                () -> new OmgPayController().collectForm(request));
+    }
+}

+ 54 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/omg/OmgCheckMacValueTest.java

@@ -0,0 +1,54 @@
+package com.ruoyi.app.utils.omg;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class OmgCheckMacValueTest {
+
+    @Test
+    void generatesOfficialEcpayLogisticsVector() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("MerchantID", "2000933");
+        params.put("MerchantTradeNo", "A20130312153023");
+        params.put("MerchantTradeDate", "2013/03/12 15:30:23");
+        params.put("LogisticsType", "CVS");
+        params.put("LogisticsSubType", "FAMIC2C");
+        params.put("GoodsAmount", "1000");
+        params.put("IsCollection", "N");
+        params.put("ServerReplyURL", "https://www.ecpay.com.tw/ServerReplyURL");
+        params.put("SenderName", "SteveJones");
+        params.put("ReceiverName", "MarkHarrison");
+        params.put("ReceiverStoreID", "001779");
+
+        assertEquals("754C5D1365035DA34D2CD91CC256F18C",
+                OmgCheckMacValue.generate(params, "XBERn1YOvpM9nfZc", "h1ONHk4P4yqbl5LK", "MD5"));
+    }
+
+    @Test
+    void verifiesSha256WithoutDependingOnHexCase() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("MerchantID", "2000132");
+        params.put("MerchantTradeNo", "OMG12345678901234567");
+        String signature = OmgCheckMacValue.generate(params, "5294y06JbISpM5x9", "v77hoKGq4kWxNNIS");
+        params.put("CheckMacValue", signature.toLowerCase());
+
+        assertTrue(OmgCheckMacValue.verify(params, "5294y06JbISpM5x9", "v77hoKGq4kWxNNIS"));
+    }
+
+    @Test
+    void rejectsMalformedOrIncorrectSignature() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("MerchantID", "2000132");
+        params.put("CheckMacValue", "not-a-sha256");
+        assertFalse(OmgCheckMacValue.verify(params, "key", "iv"));
+
+        params.put("CheckMacValue", "0".repeat(64));
+        assertFalse(OmgCheckMacValue.verify(params, "key", "iv"));
+    }
+}

+ 48 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/omg/OmgPayTest.java

@@ -0,0 +1,48 @@
+package com.ruoyi.app.utils.omg;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class OmgPayTest {
+
+    private final OmgPay omgPay = new OmgPay();
+
+    @Test
+    void parsesAndUrlDecodesKvResponse() {
+        Map<String, String> result = omgPay.parseKvResponse(
+                "MerchantTradeNo=OMG123&PaymentDate=2026%2F08%2F06+12%3A30%3A00&RtnMsg=Paid%26OK");
+
+        assertEquals("OMG123", result.get("MerchantTradeNo"));
+        assertEquals("2026/08/06 12:30:00", result.get("PaymentDate"));
+        assertEquals("Paid&OK", result.get("RtnMsg"));
+    }
+
+    @Test
+    void rejectsDuplicateAndMalformedFields() {
+        assertThrows(IllegalArgumentException.class,
+                () -> omgPay.parseKvResponse("RtnCode=1&RtnCode=0"));
+        assertThrows(IllegalArgumentException.class,
+                () -> omgPay.parseKvResponse("RtnCode=1&broken"));
+        assertThrows(IllegalArgumentException.class,
+                () -> omgPay.parseKvResponse("=value"));
+        assertThrows(IllegalArgumentException.class,
+                () -> omgPay.parseKvResponse("RtnCode=1&"));
+    }
+
+    @Test
+    void rejectsMoreThanOneHundredFields() {
+        StringBuilder response = new StringBuilder();
+        for (int i = 0; i <= 100; i++) {
+            if (i > 0) {
+                response.append('&');
+            }
+            response.append('k').append(i).append("=v");
+        }
+
+        assertThrows(IllegalArgumentException.class, () -> omgPay.parseKvResponse(response.toString()));
+    }
+}

+ 15 - 0
ruoyi-system/pom.xml

@@ -46,6 +46,11 @@
             <groupId>org.projectlombok</groupId>
             <artifactId>lombok</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>com.google.zxing</groupId>
             <artifactId>core</artifactId>
@@ -60,4 +65,14 @@
         </dependency>
     </dependencies>
 
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <version>3.2.5</version>
+            </plugin>
+        </plugins>
+    </build>
+
 </project>

+ 1 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderOmgPayment.java

@@ -59,7 +59,7 @@ public class PosOrderOmgPayment {
     /** 回调 RtnMsg */
     private String rtnMsg;
 
-    /** 0 未支付/1 已支付/2 失败/3 已退款 */
+    /** 0 未支付 / 1 已支付 / 2 失败 / 3 已退款 / 4 退款中 */
     private Integer payStatus;
 
     /** 授权码(信用卡回调) */

+ 5 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreOmgCredentialDto.java

@@ -2,6 +2,7 @@ package com.ruoyi.system.domain.dto;
 
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
 import lombok.Data;
 
 /**
@@ -19,16 +20,20 @@ public class StoreOmgCredentialDto {
 
     /** OMG 商店代号 MerchantID */
     @NotBlank(message = "商店代号不能为空")
+    @Size(max = 10, message = "商店代号长度不能超过10")
     private String merchantId;
 
     /** OMG HashKey */
     @NotBlank(message = "HashKey 不能为空")
+    @Size(max = 64, message = "HashKey 长度不能超过64")
     private String hashKey;
 
     /** OMG HashIV */
     @NotBlank(message = "HashIV 不能为空")
+    @Size(max = 64, message = "HashIV 长度不能超过64")
     private String hashIv;
 
     /** 启用支付方式(本期固定 ALL,字段保留) */
+    @Size(max = 50, message = "支付方式长度不能超过50")
     private String enabledPayments;
 }

+ 0 - 6
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosStoreOmgVo.java

@@ -46,12 +46,6 @@ public class PosStoreOmgVo {
     /** OMG 商店代号(详情用,列表可不下发) */
     private String merchantId;
 
-    /** HashKey(详情回显,仅平台后台核对) */
-    private String hashKey;
-
-    /** HashIV(详情回显,仅平台后台核对) */
-    private String hashIv;
-
     /** 启用支付方式 */
     private String enabledPayments;
 

+ 35 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderOmgPaymentMapper.java

@@ -4,6 +4,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.PosOrderOmgPayment;
 import org.apache.ibatis.annotations.Param;
 
+import java.util.Date;
+import java.util.List;
+
 /**
  * OMG 支付交易流水 Mapper。insert / updateById 用 BaseMapper。
  *
@@ -20,4 +23,36 @@ public interface PosOrderOmgPaymentMapper extends BaseMapper<PosOrderOmgPayment>
 
     /** 按订单号查最新一笔流水(取最近发起/支付记录)。 */
     PosOrderOmgPayment selectLatestByDdId(@Param("ddId") String ddId);
+
+    PosOrderOmgPayment selectLatestPaidByDdId(@Param("ddId") String ddId);
+
+    /**
+     * 扫描补单窗口内未核销的漏单 ddId(定时补单方案B 用)。
+     * 每订单取最新一笔 pay_status=0 的流水(older 发起视为放弃),关联 pos_order 排除已取消(state=4)。
+     */
+    List<String> selectLeakOrderDdIds(@Param("windowStart") Date windowStart,
+                                      @Param("graceCutoff") Date graceCutoff,
+                                      @Param("batchSize") int batchSize);
+
+    int markSuccessIfUnpaid(@Param("id") Long id, @Param("tradeNo") String tradeNo,
+                            @Param("payType") String payType, @Param("rtnCode") Integer rtnCode,
+                            @Param("rtnMsg") String rtnMsg, @Param("authCode") String authCode,
+                            @Param("payTime") java.util.Date payTime,
+                            @Param("tradeDate") java.util.Date tradeDate,
+                            @Param("callbackRaw") String callbackRaw,
+                            @Param("updateTime") java.util.Date updateTime);
+
+    int markFailIfUnpaid(@Param("id") Long id, @Param("rtnCode") Integer rtnCode,
+                         @Param("rtnMsg") String rtnMsg, @Param("callbackRaw") String callbackRaw,
+                         @Param("updateTime") java.util.Date updateTime);
+
+    int markPaymentInfoIfOpen(@Param("id") Long id, @Param("tradeNo") String tradeNo,
+                              @Param("callbackRaw") String callbackRaw,
+                              @Param("updateTime") java.util.Date updateTime);
+
+    int markRefundingIfPaid(@Param("id") Long id, @Param("updateTime") java.util.Date updateTime);
+
+    int markRefundedIfRefunding(@Param("id") Long id, @Param("updateTime") java.util.Date updateTime);
+
+    int restorePaidIfRefunding(@Param("id") Long id, @Param("updateTime") java.util.Date updateTime);
 }

+ 16 - 1
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderOmgPaymentService.java

@@ -3,6 +3,7 @@ package com.ruoyi.system.service;
 import com.ruoyi.system.domain.PosOrderOmgPayment;
 
 import java.util.Date;
+import java.util.List;
 
 /**
  * OMG 支付流水 Service(落库、幂等核销、状态流转)。
@@ -25,6 +26,8 @@ public interface IPosOrderOmgPaymentService {
     /** 按订单号查最新一笔流水。 */
     PosOrderOmgPayment getLatestByDdId(String ddId);
 
+    PosOrderOmgPayment getLatestPaidByDdId(String ddId);
+
     /**
      * 标记支付成功(幂等:已 pay_status=1 返回 0 不重复更新)。
      *
@@ -39,7 +42,7 @@ public interface IPosOrderOmgPaymentService {
      * @return 实际更新行数(0=幂等跳过)
      */
     int markSuccess(Long paymentId, String tradeNo, String payType, Integer rtnCode,
-                    String rtnMsg, String authCode, Date payTime, String callbackRaw);
+                    String rtnMsg, String authCode, Date payTime, Date tradeDate, String callbackRaw);
 
     /** 标记支付失败(pay_status=2)。 */
     int markFail(Long paymentId, Integer rtnCode, String rtnMsg, String callbackRaw);
@@ -47,6 +50,18 @@ public interface IPosOrderOmgPaymentService {
     /** 标记已退款(pay_status=3)。 */
     int markRefunded(Long paymentId);
 
+    int markRefunding(Long paymentId);
+
+    int restorePaidFromRefunding(Long paymentId);
+
     /** 记录 ATM/超商取号回调(更新 tradeNo + callbackRaw,不改 pay_status)。 */
     int markPaymentInfo(Long paymentId, String tradeNo, String callbackRaw);
+
+    /**
+     * 扫描补单窗口内未核销的漏单 ddId 列表(定时补单方案B 用)。
+     * @param windowStart  扫描起点(now - windowHours),过期单不再扫
+     * @param graceCutoff  扫描终点(now - graceMinutes),给回调/重试留送达时间
+     * @param batchSize    单轮最多处理笔数
+     */
+    List<String> scanLeakOrders(Date windowStart, Date graceCutoff, int batchSize);
 }

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosStoreOmgService.java

@@ -22,6 +22,9 @@ public interface IPosStoreOmgService {
     /** 按 OMG 商店代号反查已启用凭证(回调用)。无则 null。 */
     PosStoreOmg getEnabledCredentialByMerchantId(String merchantId);
 
+    /** 回调/退款按 MerchantID 读取已配置凭证;停用只阻止新支付,不得阻断在途交易。 */
+    PosStoreOmg getCredentialByMerchantId(String merchantId);
+
     /** 凭证管理列表(配合 PageHelper 分页)。 */
     List<PosStoreOmgVo> selectOmgStoreList(PosStoreOmgVo query);
 

+ 51 - 58
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImpl.java

@@ -7,20 +7,14 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.util.Date;
+import java.util.List;
 
-/**
- * OMG 支付流水 Service 实现。
- *
- * @author ruoyi
- * @date 2026-07-29
- */
+/** OMG payment ledger service. */
 @Service
 public class PosOrderOmgPaymentServiceImpl implements IPosOrderOmgPaymentService {
 
     private static final int PAY_STATUS_UNPAID = 0;
-    private static final int PAY_STATUS_PAID = 1;
-    private static final int PAY_STATUS_FAIL = 2;
-    private static final int PAY_STATUS_REFUNDED = 3;
+    private static final int RTN_MSG_MAX_LENGTH = 200;
 
     @Autowired
     private PosOrderOmgPaymentMapper posOrderOmgPaymentMapper;
@@ -45,87 +39,86 @@ public class PosOrderOmgPaymentServiceImpl implements IPosOrderOmgPaymentService
 
     @Override
     public PosOrderOmgPayment getByTradeNo(String tradeNo) {
-        if (tradeNo == null || tradeNo.isEmpty()) {
-            return null;
-        }
-        return posOrderOmgPaymentMapper.selectByTradeNo(tradeNo);
+        return tradeNo == null || tradeNo.isEmpty() ? null : posOrderOmgPaymentMapper.selectByTradeNo(tradeNo);
     }
 
     @Override
     public PosOrderOmgPayment getByMerchantTradeNo(String merchantTradeNo) {
-        if (merchantTradeNo == null || merchantTradeNo.isEmpty()) {
-            return null;
-        }
-        return posOrderOmgPaymentMapper.selectByMerchantTradeNo(merchantTradeNo);
+        return merchantTradeNo == null || merchantTradeNo.isEmpty()
+                ? null : posOrderOmgPaymentMapper.selectByMerchantTradeNo(merchantTradeNo);
     }
 
     @Override
     public PosOrderOmgPayment getLatestByDdId(String ddId) {
-        if (ddId == null || ddId.isEmpty()) {
-            return null;
-        }
-        return posOrderOmgPaymentMapper.selectLatestByDdId(ddId);
+        return ddId == null || ddId.isEmpty() ? null : posOrderOmgPaymentMapper.selectLatestByDdId(ddId);
+    }
+
+    @Override
+    public PosOrderOmgPayment getLatestPaidByDdId(String ddId) {
+        return ddId == null || ddId.isEmpty() ? null : posOrderOmgPaymentMapper.selectLatestPaidByDdId(ddId);
     }
 
     @Override
     public int markSuccess(Long paymentId, String tradeNo, String payType, Integer rtnCode,
-                           String rtnMsg, String authCode, Date payTime, String callbackRaw) {
-        PosOrderOmgPayment row = posOrderOmgPaymentMapper.selectById(paymentId);
-        if (row == null) {
+                           String rtnMsg, String authCode, Date payTime, Date tradeDate, String callbackRaw) {
+        if (paymentId == null) {
             return 0;
         }
-        // 幂等:已支付则跳过(重复回调)
-        if (Integer.valueOf(PAY_STATUS_PAID).equals(row.getPayStatus())) {
-            return 0;
-        }
-        row.setTradeNo(tradeNo);
-        row.setPayStatus(PAY_STATUS_PAID);
-        row.setPayType(payType);
-        row.setRtnCode(rtnCode);
-        row.setRtnMsg(rtnMsg);
-        row.setAuthCode(authCode);
-        row.setPayTime(payTime);
-        row.setCallbackRaw(callbackRaw);
-        row.setUpdateTime(new Date());
-        return posOrderOmgPaymentMapper.updateById(row);
+        // CAS: only an open attempt can become paid; refunded/refunding attempts are terminal.
+        return posOrderOmgPaymentMapper.markSuccessIfUnpaid(paymentId, tradeNo, payType, rtnCode,
+                truncateRtnMsg(rtnMsg), authCode, payTime, tradeDate, callbackRaw, new Date());
     }
 
     @Override
     public int markFail(Long paymentId, Integer rtnCode, String rtnMsg, String callbackRaw) {
-        PosOrderOmgPayment row = posOrderOmgPaymentMapper.selectById(paymentId);
-        if (row == null) {
+        if (paymentId == null) {
             return 0;
         }
-        row.setPayStatus(PAY_STATUS_FAIL);
-        row.setRtnCode(rtnCode);
-        row.setRtnMsg(rtnMsg);
-        row.setCallbackRaw(callbackRaw);
-        row.setUpdateTime(new Date());
-        return posOrderOmgPaymentMapper.updateById(row);
+        return posOrderOmgPaymentMapper.markFailIfUnpaid(paymentId, rtnCode,
+                truncateRtnMsg(rtnMsg), callbackRaw, new Date());
     }
 
     @Override
     public int markRefunded(Long paymentId) {
-        PosOrderOmgPayment row = posOrderOmgPaymentMapper.selectById(paymentId);
-        if (row == null) {
+        if (paymentId == null) {
             return 0;
         }
-        row.setPayStatus(PAY_STATUS_REFUNDED);
-        row.setUpdateTime(new Date());
-        return posOrderOmgPaymentMapper.updateById(row);
+        return posOrderOmgPaymentMapper.markRefundedIfRefunding(paymentId, new Date());
+    }
+
+    @Override
+    public int markRefunding(Long paymentId) {
+        if (paymentId == null) {
+            return 0;
+        }
+        return posOrderOmgPaymentMapper.markRefundingIfPaid(paymentId, new Date());
+    }
+
+    @Override
+    public int restorePaidFromRefunding(Long paymentId) {
+        if (paymentId == null) {
+            return 0;
+        }
+        return posOrderOmgPaymentMapper.restorePaidIfRefunding(paymentId, new Date());
     }
 
     @Override
     public int markPaymentInfo(Long paymentId, String tradeNo, String callbackRaw) {
-        PosOrderOmgPayment row = posOrderOmgPaymentMapper.selectById(paymentId);
-        if (row == null) {
+        if (paymentId == null) {
             return 0;
         }
-        if (tradeNo != null && !tradeNo.isEmpty()) {
-            row.setTradeNo(tradeNo);
+        return posOrderOmgPaymentMapper.markPaymentInfoIfOpen(paymentId, tradeNo, callbackRaw, new Date());
+    }
+
+    @Override
+    public List<String> scanLeakOrders(Date windowStart, Date graceCutoff, int batchSize) {
+        return posOrderOmgPaymentMapper.selectLeakOrderDdIds(windowStart, graceCutoff, Math.max(1, batchSize));
+    }
+
+    private String truncateRtnMsg(String rtnMsg) {
+        if (rtnMsg == null || rtnMsg.length() <= RTN_MSG_MAX_LENGTH) {
+            return rtnMsg;
         }
-        row.setCallbackRaw(callbackRaw);
-        row.setUpdateTime(new Date());
-        return posOrderOmgPaymentMapper.updateById(row);
+        return rtnMsg.substring(0, RTN_MSG_MAX_LENGTH);
     }
 }

+ 10 - 1
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderOmgRefundServiceImpl.java

@@ -18,6 +18,8 @@ import java.util.List;
 @Service
 public class PosOrderOmgRefundServiceImpl implements IPosOrderOmgRefundService {
 
+    private static final int RTN_MSG_MAX_LENGTH = 200;
+
     @Autowired
     private PosOrderOmgRefundMapper posOrderOmgRefundMapper;
 
@@ -31,7 +33,7 @@ public class PosOrderOmgRefundServiceImpl implements IPosOrderOmgRefundService {
         row.setAction(action);
         row.setAmount(amount);
         row.setRtnCode(rtnCode);
-        row.setRtnMsg(rtnMsg);
+        row.setRtnMsg(truncateRtnMsg(rtnMsg));
         row.setCallbackRaw(callbackRaw);
         row.setCreateTime(new Date());
         return posOrderOmgRefundMapper.insert(row);
@@ -52,4 +54,11 @@ public class PosOrderOmgRefundServiceImpl implements IPosOrderOmgRefundService {
         }
         return posOrderOmgRefundMapper.selectByDdId(ddId);
     }
+
+    private String truncateRtnMsg(String rtnMsg) {
+        if (rtnMsg == null || rtnMsg.length() <= RTN_MSG_MAX_LENGTH) {
+            return rtnMsg;
+        }
+        return rtnMsg.substring(0, RTN_MSG_MAX_LENGTH);
+    }
 }

+ 27 - 3
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosStoreOmgServiceImpl.java

@@ -1,6 +1,7 @@
 package com.ruoyi.system.service.impl;
 
 import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.system.domain.PosStoreOmg;
@@ -48,6 +49,18 @@ public class PosStoreOmgServiceImpl implements IPosStoreOmgService {
         return enabledOrNull(posStoreOmgMapper.selectByMerchantId(merchantId));
     }
 
+    @Override
+    public PosStoreOmg getCredentialByMerchantId(String merchantId) {
+        if (StrUtil.isBlank(merchantId)) {
+            return null;
+        }
+        PosStoreOmg row = posStoreOmgMapper.selectByMerchantId(merchantId);
+        if (row == null || StrUtil.isBlank(row.getHashKey()) || StrUtil.isBlank(row.getHashIv())) {
+            return null;
+        }
+        return row;
+    }
+
     @Override
     public List<PosStoreOmgVo> selectOmgStoreList(PosStoreOmgVo query) {
         return posStoreOmgMapper.selectOmgStoreList(query);
@@ -76,6 +89,9 @@ public class PosStoreOmgServiceImpl implements IPosStoreOmgService {
     @Override
     public int enableWithCredentials(StoreOmgCredentialDto dto) {
         PosStoreOmg row = getOrCreateByStoreId(dto.getStoreId());
+        if (!Integer.valueOf(STATUS_APPLYING).equals(row.getOmgStatus())) {
+            throw new ServiceException("当前状态不允许录入凭证");
+        }
         row.setMerchantId(dto.getMerchantId());
         row.setHashKey(dto.getHashKey());
         row.setHashIv(dto.getHashIv());
@@ -105,9 +121,17 @@ public class PosStoreOmgServiceImpl implements IPosStoreOmgService {
     @Override
     public int reset(Long storeId) {
         PosStoreOmg row = getOrCreateByStoreId(storeId);
-        row.setOmgStatus(STATUS_APPLYING);
-        row.setApprovedTime(null);
-        return saveOrUpdate(row);
+        if (row.getId() == null) {
+            throw new ServiceException("门店尚未发起 OMG 申请");
+        }
+        Date now = new Date();
+        return posStoreOmgMapper.update(null, Wrappers.<PosStoreOmg>lambdaUpdate()
+                .eq(PosStoreOmg::getId, row.getId())
+                .set(PosStoreOmg::getOmgStatus, STATUS_APPLYING)
+                .set(PosStoreOmg::getIsEnabled, 0)
+                .set(PosStoreOmg::getApprovedTime, null)
+                .set(PosStoreOmg::getUpdateTime, now)
+                .set(PosStoreOmg::getUpdateBy, currentUser()));
     }
 
     @Override

+ 78 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgPaymentMapper.xml

@@ -39,4 +39,82 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         LIMIT 1
     </select>
 
+    <select id="selectLatestPaidByDdId" parameterType="String"
+            resultType="com.ruoyi.system.domain.PosOrderOmgPayment">
+        SELECT <include refid="paymentColumns"/>
+        FROM pos_order_omg_payment
+        WHERE dd_id = #{ddId}
+          AND pay_status = 1
+          AND trade_no IS NOT NULL
+          AND trade_no &lt;&gt; ''
+        ORDER BY id DESC
+        LIMIT 1
+    </select>
+
+    <!-- 扫描补单窗口内未核销漏单(每订单最新一笔 pay_status=0),关联订单排除已取消 state=4 -->
+    <select id="selectLeakOrderDdIds" resultType="java.lang.String">
+        SELECT p.dd_id
+        FROM pos_order_omg_payment p
+        INNER JOIN pos_order o ON o.dd_id = p.dd_id
+        INNER JOIN (
+            SELECT MAX(id) AS max_id
+            FROM pos_order_omg_payment
+            WHERE pay_status = 0
+              AND create_time &gt;= #{windowStart}
+              AND create_time &lt;= #{graceCutoff}
+            GROUP BY dd_id
+        ) latest ON latest.max_id = p.id
+        WHERE (o.state IS NULL OR o.state &lt;&gt; 4)
+        ORDER BY p.id ASC
+        LIMIT #{batchSize}
+    </select>
+
+    <update id="markSuccessIfUnpaid">
+        UPDATE pos_order_omg_payment
+        SET trade_no = #{tradeNo}, pay_status = 1, pay_type = #{payType},
+            rtn_code = #{rtnCode}, rtn_msg = #{rtnMsg}, auth_code = #{authCode},
+            pay_time = #{payTime}, trade_date = #{tradeDate}, callback_raw = #{callbackRaw},
+            update_time = #{updateTime}
+        WHERE id = #{id}
+          AND pay_status IN (0, 2)
+          AND #{tradeNo} IS NOT NULL
+          AND #{tradeNo} &lt;&gt; ''
+          AND (trade_no IS NULL OR trade_no = '' OR trade_no = #{tradeNo})
+    </update>
+
+    <update id="markFailIfUnpaid">
+        UPDATE pos_order_omg_payment
+        SET pay_status = 2, rtn_code = #{rtnCode}, rtn_msg = #{rtnMsg},
+            callback_raw = #{callbackRaw}, update_time = #{updateTime}
+        WHERE id = #{id} AND pay_status = 0
+    </update>
+
+    <update id="markPaymentInfoIfOpen">
+        UPDATE pos_order_omg_payment
+        SET trade_no = CASE WHEN #{tradeNo} IS NULL OR #{tradeNo} = '' THEN trade_no ELSE #{tradeNo} END,
+            callback_raw = #{callbackRaw}, update_time = #{updateTime}
+        WHERE id = #{id}
+          AND pay_status = 0
+          AND (#{tradeNo} IS NULL OR #{tradeNo} = ''
+               OR trade_no IS NULL OR trade_no = '' OR trade_no = #{tradeNo})
+    </update>
+
+    <update id="markRefundingIfPaid">
+        UPDATE pos_order_omg_payment
+        SET pay_status = 4, update_time = #{updateTime}
+        WHERE id = #{id} AND pay_status = 1
+    </update>
+
+    <update id="markRefundedIfRefunding">
+        UPDATE pos_order_omg_payment
+        SET pay_status = 3, update_time = #{updateTime}
+        WHERE id = #{id} AND pay_status = 4
+    </update>
+
+    <update id="restorePaidIfRefunding">
+        UPDATE pos_order_omg_payment
+        SET pay_status = 1, update_time = #{updateTime}
+        WHERE id = #{id} AND pay_status = 4
+    </update>
+
 </mapper>

+ 0 - 2
ruoyi-system/src/main/resources/mapper/chanting/PosStoreOmgMapper.xml

@@ -77,8 +77,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             IFNULL(e.is_enabled, 1)  AS isEnabled,
             CASE WHEN e.merchant_id IS NULL OR e.merchant_id = '' THEN 0 ELSE 1 END AS hasMerchantId,
             e.merchant_id     AS merchantId,
-            e.hash_key        AS hashKey,
-            e.hash_iv         AS hashIv,
             e.enabled_payments AS enabledPayments,
             e.remark          AS remark,
             e.apply_time      AS applyTime,

+ 72 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderOmgPaymentServiceImplTest.java

@@ -0,0 +1,72 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.system.mapper.PosOrderOmgPaymentMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PosOrderOmgPaymentServiceImplTest {
+
+    @Mock
+    private PosOrderOmgPaymentMapper paymentMapper;
+
+    @InjectMocks
+    private PosOrderOmgPaymentServiceImpl paymentService;
+
+    @Test
+    void markSuccessDoesNothingWithoutPaymentId() {
+        int updated = paymentService.markSuccess(null, "trade-no", "Credit_CreditCard", 1,
+                "ok", "123456", new Date(), new Date(), "{}");
+
+        assertEquals(0, updated);
+        verify(paymentMapper, never()).markSuccessIfUnpaid(any(), any(), any(), any(), any(),
+                any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void markSuccessTruncatesGatewayMessageToDatabaseLimit() {
+        String longMessage = "x".repeat(250);
+        when(paymentMapper.markSuccessIfUnpaid(eq(1L), eq("trade-no"), eq("Credit_CreditCard"),
+                eq(1), eq("x".repeat(200)), eq("123456"), any(), any(), eq("{}"), any()))
+                .thenReturn(1);
+
+        int updated = paymentService.markSuccess(1L, "trade-no", "Credit_CreditCard", 1,
+                longMessage, "123456", new Date(), new Date(), "{}");
+
+        assertEquals(1, updated);
+    }
+
+    @Test
+    void markFailTruncatesGatewayMessageToDatabaseLimit() {
+        String longMessage = "y".repeat(250);
+        when(paymentMapper.markFailIfUnpaid(eq(2L), eq(0), eq("y".repeat(200)), eq("{}"), any()))
+                .thenReturn(1);
+
+        int updated = paymentService.markFail(2L, 0, longMessage, "{}");
+
+        assertEquals(1, updated);
+    }
+
+    @Test
+    void refundTransitionsUseCompareAndSetMapperMethods() {
+        when(paymentMapper.markRefundingIfPaid(eq(3L), any())).thenReturn(1);
+        when(paymentMapper.markRefundedIfRefunding(eq(3L), any())).thenReturn(1);
+        when(paymentMapper.restorePaidIfRefunding(eq(3L), any())).thenReturn(1);
+
+        assertEquals(1, paymentService.markRefunding(3L));
+        assertEquals(1, paymentService.markRefunded(3L));
+        assertEquals(1, paymentService.restorePaidFromRefunding(3L));
+    }
+}

+ 38 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderOmgRefundServiceImplTest.java

@@ -0,0 +1,38 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.system.domain.PosOrderOmgRefund;
+import com.ruoyi.system.mapper.PosOrderOmgRefundMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PosOrderOmgRefundServiceImplTest {
+
+    @Mock
+    private PosOrderOmgRefundMapper refundMapper;
+
+    @InjectMocks
+    private PosOrderOmgRefundServiceImpl refundService;
+
+    @Test
+    void recordTruncatesGatewayMessageToDatabaseLimit() {
+        ArgumentCaptor<PosOrderOmgRefund> rowCaptor = ArgumentCaptor.forClass(PosOrderOmgRefund.class);
+        when(refundMapper.insert(any(PosOrderOmgRefund.class))).thenReturn(1);
+
+        int inserted = refundService.record(1L, "order-1", "trade-1", "R", 100,
+                0, "z".repeat(250), "{}");
+
+        assertEquals(1, inserted);
+        verify(refundMapper).insert(rowCaptor.capture());
+        assertEquals("z".repeat(200), rowCaptor.getValue().getRtnMsg());
+    }
+}

+ 135 - 0
specs/016-omg-payment/callback-reconcile.md

@@ -0,0 +1,135 @@
+# OMG 支付回调可靠性 / 漏单补单 — 设计文档(待实现)
+
+**Feature**: specs/016-omg-payment | **状态**: ✅ 方案A+B 后端已实现(2026-08-07,tasks T042–T048);测试(T049)/前端配合(T050)待后续 | **关联**: [api.md](contracts/api.md) §A4/B5/B8
+
+> 本文档解决一个核心隐患:**异步回调不可靠导致漏单**。当前后端是纯被动等回调,回调一旦丢失,用户付了钱订单却永远停在「未支付」。下面是问题分析 + 补单方案,留待后续实现。
+
+---
+
+## 1. 问题:两种「回调没成功」,影响完全不同
+
+| 场景 | 触发原因 | 当前覆盖 | 影响 |
+|------|---------|---------|------|
+| **① 回调延迟** | OMG 服务端 POST 晚到(网络抖动/排队) | OMG 自动重试(5–15分/次,当天最多4次);后端处理完即回 `1|OK` | ✅ 能自愈,钱不丢。仅前端结果页短暂显示「确认中」 |
+| **② 回调丢失(漏单)** | 后端宕机/重启/断网,OMG 重试4次仍未送达,之后不再重试 | ❌ **无任何兜底** | 🔴 用户钱扣了,订单永远 `pay_status=0` |
+
+**漏单的真实触发条件**:后端在 OMG 重试窗口(当天4次)内一直不可用。
+
+---
+
+## 2. 当前后端盲区(为什么必须补)
+
+1. **纯被动**:`OmgPayController.notify` 只能等 OMG POST 过来,不主动核对。
+2. **查询能力已具备但未接入**:`OmgPay.queryTrade()`(调 `QueryTradeInfo/V5`,自带验签+关联校验,返回 `TradeStatus` 0未付/1已付/10200095失败)已实现,目前**只用于门店凭证验证**,没暴露成补单接口。
+3. **contracts 规划的 `POST /pay/omg/query`(§B5)标了「可选」,未实现。**
+4. **原定时兜底 `TestTask.java` 已废弃**(全量废弃,含超时退款/抽成返还等),不能往里加。
+
+---
+
+## 3. 顺带:notify「全部回 1|OK」的语义
+
+`notify` 在所有分支(成功/验签失败/金额不符/异常)都返回纯串 `1|OK`。这是 OMG 协议要求(回非 `1|OK` 会触发重试,但重试对校验类失败无用)。需区分两种「回调失败」:
+
+| 情况 | 后端行为 | 正确性 |
+|------|---------|--------|
+| **校验失败**(假回调/金额对不上) | 回 `1|OK` 吞掉,**不改订单** | ✅ 防伪造改单,正确 |
+| **送达失败**(后端没收到) | 无从回 `1|OK`,OMG 重试4次后放弃 | ❌ 漏单,靠本文档方案补 |
+
+> 另:若 `markSuccess` 成功后、推送前崩溃——订单状态已更新,无碍(推送漏发不影响,用户刷新订单即见已支付);若 `markSuccess` 之前崩溃,OMG 会重试时补上。死穴仅「重试窗口内后端持续不可用」。
+
+---
+
+## 4. 方案A:被动补单接口(推荐先做)
+
+前端结果页轮询时,订单长时间仍 `payStatus=0` 即调用,后端去 OMG 查真实状态并补单。
+
+### 接口契约: `POST /pay/omg/query`
+
+| 项 | 内容 |
+|----|------|
+| Method | `POST` |
+| Header | `token: <用户JWT>` |
+| 入参 | query:`orderid` = ddId |
+| 鉴权 | 登录用户 + 订单本人 |
+| 复用 | `OmgPay.queryTrade()` + `IPosOrderOmgPaymentService.markSuccess()` + `handlePaymentSuccess()`(同回调核销链路) |
+
+**返回**
+```json
+{ "code": 200, "data": { "payStatus": 1, "reconciled": true } }
+// payStatus: 0未付 / 1已付(含本次补单) / 2失败 ; reconciled: 本次是否触发了补单
+```
+
+**核心逻辑(实现时照此)**
+```
+1. 校验登录 + 订单归属(token → userId == order.userId)
+2. order.payType 必须为 "7";若 order.payStatus 已为 1 → 直接返回成功(不重复处理)
+3. payment = paymentService.getLatestByDdId(ddId)  // 最新一条 OMG 流水
+4. 门店凭证 cred = storeOmgService.getEnabledCredential(order.mdId) → OmgPayConfig
+5. resp = omgPay.queryTrade(baseUrl, cfg, payment.getMerchantTradeNo)
+6. 按 resp.TradeStatus 分支:
+     "1" 已付:
+        - 先判断 payment.payStatus == 1 → 已被回调处理过,直接返回成功(幂等)
+        - 否则 markSuccess(payment.id, resp.TradeNo, resp.PaymentType, 1, ...) + handlePaymentSuccess(order)
+        - 返回 { payStatus:1, reconciled:true }
+     "0" 未付:
+        - 返回 { payStatus:0, reconciled:false }   // 前端继续轮询(延期支付会长期是0,正常)
+     "10200095" / 其他:
+        - paymentService.markFail(...) ; 返回 { payStatus:2, reconciled:false }
+```
+
+### 关键:幂等
+补单与回调可能并发/先后到达。**`markSuccess` 必须按 `trade_no` 幂等**(回调侧已如此);补单前先查 `payment.payStatus==1` 则直接返回,避免重复 `markSuccess` + 重复推送。
+
+---
+
+## 5. 方案B:定时任务兜底(建议补,A+B 最稳)
+
+防的是「用户付完就关 App、回调又丢了」的极端情况——用户不会再回来触发方案A。
+
+**新建独立定时任务**(RuoYi quartz 或 `@Scheduled`,**严禁**改废弃的 `TestTask.java`):
+- 扫描:`pos_order_omg_payment` 中 `pay_status=0` 且 `create_time` 在补单窗口内的流水,且对应订单未取消(`state != 4`)
+- 对每条:同方案A步骤 4–6(queryTrade → 补单/标失败)
+- 频率:每 2~3 分钟一轮
+- 补单窗口:**需覆盖延期支付周期**
+  - 信用卡即时:发起后 30 分钟内未付基本可判定异常
+  - ATM/超商(延期):虚帐/缴费码有 `ExpireDate`(1~3 天),`TradeStatus` 在用户实际缴款前一直是 `0`;窗口应覆盖到 `ExpireDate` 之后(如发起后 7 天),过期停止扫描避免无限轮询
+- 并发保护:多实例部署时需加分布式锁或 `SELECT ... FOR UPDATE`,避免同一流水被多节点同时补单(幂等能兜底,但减少无效调用)
+
+---
+
+## 6. 前端配合(方案A 触发点)
+
+接入文档 [frontend-integration.md](frontend-integration.md) §4.3 的结果页轮询,当轮询超过阈值(如 30s)仍 `payStatus=0` 时,**改为主动调补单接口**而非继续干等:
+
+```
+轮询 paymentInfo → payStatus=0 持续 > 30s
+   → 调 POST /pay/omg/query?orderid=   // 后端查 OMG 真实状态补单
+   → 据返回 payStatus 展示成功/失败/继续等
+```
+
+(实现方案A后,回头更新 frontend-integration.md 的轮询段)
+
+---
+
+## 7. 实现清单
+
+- [x] **方案A**:`OmgPayController` 加 `POST /pay/omg/query`(@Anonymous @Auth),复用 queryTrade + applyPaidResult(markSuccess+handlePaymentSuccess),严格幂等(T044)
+- [x] **共享逻辑抽取**:`applyPaidResult()`(notify 成功分支改调此) + `reconcileByQuery()`(/query 与定时任务共用,含跨事务中断自愈)(T043)
+- [x] **方案B**:新建 `OmgReconcileTask`(@Scheduled 每3分钟 + Redisson 分布式锁)扫漏单,复用 reconcileByQuery(T046);scan mapper+service(T045);@EnableScheduling(T046);`omg.reconcile.*` 配置(T047)
+- [x] **补单窗口/延期 ExpireDate**:默认 7 天窗口覆盖 ATM/超商 ExpireDate,宽限期 2 分钟让回调送达,窗口外停扫(T047)
+- [x] **补单操作写订单日志**:`orderLogHelper.logSync(ddId, ..., "OMG查询补单成功/失败/金额不符")`,便于对账
+- [ ] **方案A/B 测试**(T049):测试卡付款成功但模拟不触发回调 → 调 /query 补单;回调+补单并发不重复;定时任务扫描补单 —— 待测试环境(stage+测试卡)就绪后随 T040 端到端验证
+- [ ] **前端配合**(T050):更新 `frontend-integration.md` §4.3 轮询逻辑接入 /query —— ⏸️ 推迟:客户 uni-app 不在当前工作区(同 T017/T024)
+
+---
+
+## 附:已具备的可复用能力(不用重写)
+
+| 能力 | 位置 | 说明 |
+|------|------|------|
+| 查询 OMG 交易 | `OmgPay.queryTrade(baseUrl, cfg, merchantTradeNo)` | 返回 Map(TradeStatus/TradeNo/TradeAmt/PaymentDate/PaymentType...),自带验签 |
+| 核销成功 | `IPosOrderOmgPaymentService.markSuccess(...)` | notify 侧已用,按 trade_no 幂等 |
+| 标记失败 | `IPosOrderOmgPaymentService.markFail(...)` | 同上 |
+| 支付成功业务 | `OmgPayController.handlePaymentSuccess(order)` | 更新订单 state=0/payStatus=1 + 日志 + 推送(私有方法,补单可复用) |
+| 查流水 | `paymentService.getLatestByDdId(ddId)` | 拿最新一条 OMG 流水 |
+| TradeStatus 枚举 | contracts/api.md §A4 | `0`未付 / `1`已付 / `10200095`失败 |

+ 22 - 3
specs/016-omg-payment/contracts/api.md

@@ -66,9 +66,19 @@
 
 - 处理 A3:验签 → 落虚帐/缴费码 + 期限 → 回 `1|OK`
 
-### B5. 订单查询/补单 — `POST /pay/omg/query`(US2 补单,可选)
-
-- 入参 `orderid` → 调 A4 → 据 TradeStatus 补单/查状态
+### B5. 订单查询/被动补单 — `POST /pay/omg/query`(US6 方案A,@Anonymous @Auth,Header token)
+
+- **入参**:`orderid`(= ddId)
+- **鉴权**:登录用户 + 订单本人(`token → userId == order.userId`);`order.payType` 必须为 `"7"`
+- **逻辑**:订单已 `payStatus∈{1,2}` 直接返回不查 OMG;否则复用 `OmgPay.queryTrade()`(§A4) → 按 `TradeStatus` 分支(见下),严格幂等(`markSuccess` 按 trade_no CAS,回调+补单并发不重复核销/推送)
+- **复用**:`paymentService.getLatestByDdId` + `storeOmgService.getCredentialByMerchantId` + `OmgPayController.reconcileByQuery()`(与定时补单共用)→ `applyPaidResult()`(markSuccess + `handlePaymentSuccess` 推送,与 notify 同链路)
+- **返回**:
+  ```json
+  { "code": 200, "data": { "payStatus": 1, "reconciled": true } }
+  // payStatus: 0未付 / 1已付(含本次补单) / 2失败 ; reconciled: 本次是否触发了补单核销
+  ```
+- **TradeStatus 分支**:`1` 已付 → 金额/字段校验通过 → `applyPaidResult` 补单 + 返回 `{1, true}`;`10200095` 失败 → `markFail` + 返回 `{2, false}`;`0` 未付(延期支付付款前常态) 或其他未知值 → 保持未支付 + 返回 `{0, false}`(前端继续轮询 / 定时任务下轮再查)
+- **自愈**:若流水已 `pay_status=1` 但订单未核销(跨事务中断残留),补推订单状态,避免「付了钱订单永远未支付」
 
 ### B6. 订单取消退款(US4)
 
@@ -80,6 +90,15 @@
 - `GET /list`(分页筛选)、`GET /{storeId}`、`PUT /apply/{storeId}`、`PUT /saveCredentials`(录入+QueryTradeInfo 探测验证)、`PUT /toggleEnable/{storeId}`、`PUT /reset/{storeId}`、`PUT /enabledPayments/{storeId}`
 - 镜像 011 `PosStoreNewebpayController` 模式,但**独立 Controller/Service/实体/表**
 
+### B8. 漏单定时补单 — `OmgReconcileTask`(US6 方案B,@Scheduled + Redisson)
+
+- **触发**:`@Scheduled(fixedDelay)` 默认每 3 分钟一轮(`omg.reconcile.fixed-delay-ms`),上一轮跑完才开始计时,启动延迟 60s
+- **扫描**:`paymentService.scanLeakOrders(windowStart, graceCutoff, batchSize)` → `pos_order_omg_payment` 中 `pay_status=0` 且 `create_time` 落在 `[now-windowHours, now-graceMinutes]`、每订单取最新一笔、`INNER JOIN pos_order` 排除已取消(`state≠4`)
+- **补单**:逐笔调 `OmgPayController.reconcileByQuery(ddId,"scheduled")`(与 §B5 被动补单同一核销链路)
+- **补单窗口**:默认 `window-hours=168`(7 天),覆盖延期支付(ATM/超商) `ExpireDate`(1~3 天);`grace-minutes=2` 给回调/OMG重试留送达时间;窗口外停止扫描(避免无限轮询)
+- **多实例并发保护**:Redisson 分布式锁 `lock:omg:reconcile`(`tryLock` 拿不到即让出),保证同一轮只一个节点执行;幂等能兜底,锁减少无效调用
+- **配置**:`application.yml` → `omg.reconcile.{fixed-delay-ms, initial-delay-ms, window-hours, grace-minutes, batch-size, lock-wait-seconds, lock-lease-seconds}`
+
 ---
 
 ## C. 代码表与枚举(OMG reference 02–05,2026-07-29 补读)

+ 15 - 13
specs/016-omg-payment/data-model.md

@@ -21,7 +21,7 @@
 | store_id | BIGINT UK | 关联 pos_store.id(门店),唯一 |
 | omg_status | INT | 申请状态: 0 未申请/1 申请中/2 已开通 |
 | is_enabled | INT | 启用开关: 0 停用/1 启用(仅 status=2 有意义) |
-| merchant_id | VARCHAR(10) | OMG 商店代号 MerchantID |
+| merchant_id | VARCHAR(10) UK | OMG 商店代号 MerchantID;回调反查门店凭证,非空时唯一 |
 | hash_key | VARCHAR(64) | OMG HashKey |
 | hash_iv | VARCHAR(64) | OMG HashIV |
 | enabled_payments | VARCHAR(50) | 启用支付方式(本期固定 ALL,字段保留) |
@@ -44,16 +44,16 @@
 |------|------|------|
 | id | BIGINT PK AUTO | 主键 |
 | dd_id | VARCHAR(64) | 系统订单号(关联 pos_order.dd_id),索引 |
-| merchant_trade_no | VARCHAR(20) | 商店交易编号 MerchantTradeNo(发起生成),索引 |
+| merchant_trade_no | VARCHAR(20) | 商店交易编号 MerchantTradeNo(发起生成),唯一索引 |
 | trade_no | VARCHAR(20) | OMG 交易编号 TradeNo(回调获得),唯一索引(幂等键) |
 | store_id | BIGINT | 门店ID |
 | merchant_id | VARCHAR(10) | OMG 商店代号(发起时门店凭证) |
 | choose_payment | VARCHAR(20) | 发起方式:ALL |
-| pay_type | VARCHAR(20) | 回覆 PaymentType: Credit_CreditCard/BarcodeATM_CHINATRUST/ATM_*/CVS_*/AFTEE_AFTEE |
+| pay_type | VARCHAR(32) | 回覆 PaymentType: Credit_CreditCard/BarcodeATM_CHINATRUST/ATM_*/CVS_*/AFTEE_AFTEE |
 | amount | INT | 交易金额(整数元 = 订单 amount) |
 | rtn_code | INT | 回调 RtnCode(1=成功) |
 | rtn_msg | VARCHAR(200) | 回调 RtnMsg |
-| pay_status | INT | 0 未支付/1 已支付/2 失败/3 已退款 |
+| pay_status | INT | 0 未支付/1 已支付/2 失败/3 已退款/4 退款中 |
 | auth_code | VARCHAR(20) | 授权码(信用卡回调) |
 | pay_time | DATETIME | 支付完成时间(回调 PaymentDate) |
 | trade_date | DATETIME | 订单创建时间(回调 TradeDate) |
@@ -61,7 +61,7 @@
 | create_time / update_time | DATETIME | 审计时间 |
 
 **幂等规则**:回调按 `trade_no` 查重——已存在且 `pay_status=1` 则跳过更新(仍回 `1|OK`);不存在则新增/更新。
-**唯一性**:`trade_no` 唯一索引(MySQL 允许多个 NULL,发起时尚无 trade_no);同一订单多次发起产生多行(不同 merchant_trade_no),但同一 trade_no 只对应一行
+**唯一性**:`merchant_trade_no` 和 `trade_no` 均为唯一索引(MySQL 允许 `trade_no` 存在多个 NULL,发起时尚无 trade_no);同一订单多次发起产生多行,但每个网关交易号只能关联一笔发起记录
 
 ---
 
@@ -90,7 +90,7 @@
 
 | 字段 | 现状 | 本期用法 |
 |------|------|----------|
-| dd_id | 订单号 | 派生 MerchantTradeNo = "OMG" + dd_id |
+| dd_id | 订单号 | 与支付流水关联;MerchantTradeNo 独立生成且不可复用 |
 | amount | 订单合计金额(Integer 元) | OMG 支付金额(含运费;与发票 amount−freight 不同) |
 | pay_type | 支付方式字符串 | 新增取值 `"7"` = OMG 在线支付(发起时写入) |
 | pay_status | 0 未支付/1 已支付/2 已退款 | 回调成功置 1(沿用);退款置 2 |
@@ -123,7 +123,8 @@ CREATE TABLE pos_store_omg (
   create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人',
   update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人',
   PRIMARY KEY (id),
-  UNIQUE KEY uk_store_id (store_id)
+  UNIQUE KEY uk_store_id (store_id),
+  UNIQUE KEY uk_merchant_id (merchant_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店OMG(歐買尬)支付凭证';
 
 -- 2026-07-29 OMG支付接入 - OMG支付交易流水表
@@ -132,14 +133,14 @@ CREATE TABLE pos_order_omg_payment (
   dd_id VARCHAR(64) NOT NULL COMMENT '系统订单号(关联 pos_order.dd_id)',
   merchant_trade_no VARCHAR(20) NOT NULL COMMENT '商店交易编号 MerchantTradeNo',
   trade_no VARCHAR(20) DEFAULT NULL COMMENT 'OMG交易编号 TradeNo(幂等键)',
-  store_id BIGINT DEFAULT NULL COMMENT '门店ID',
-  merchant_id VARCHAR(10) DEFAULT NULL COMMENT 'OMG商店代号',
+  store_id BIGINT NOT NULL COMMENT '门店ID',
+  merchant_id VARCHAR(10) NOT NULL COMMENT 'OMG商店代号',
   choose_payment VARCHAR(20) DEFAULT 'ALL' COMMENT '发起方式',
-  pay_type VARCHAR(20) DEFAULT NULL COMMENT '回覆PaymentType:Credit_CreditCard/ATM_*/CVS_*/AFTEE_AFTEE/BarcodeATM_CHINATRUST',
-  amount INT DEFAULT NULL COMMENT '交易金额(整数元)',
+  pay_type VARCHAR(32) DEFAULT NULL COMMENT '回覆PaymentType:Credit_CreditCard/ATM_*/CVS_*/AFTEE_AFTEE/BarcodeATM_CHINATRUST',
+  amount INT NOT NULL COMMENT '交易金额(整数元)',
   rtn_code INT DEFAULT NULL COMMENT '回调RtnCode(1=成功)',
   rtn_msg VARCHAR(200) DEFAULT NULL COMMENT '回调RtnMsg',
-  pay_status INT NOT NULL DEFAULT 0 COMMENT '0未支付/1已支付/2失败/3已退款',
+  pay_status INT NOT NULL DEFAULT 0 COMMENT '0未支付/1已支付/2失败/3已退款/4退款中',
   auth_code VARCHAR(20) DEFAULT NULL COMMENT '授权码',
   pay_time DATETIME DEFAULT NULL COMMENT '支付完成时间',
   trade_date DATETIME DEFAULT NULL COMMENT '订单创建时间',
@@ -148,8 +149,9 @@ CREATE TABLE pos_order_omg_payment (
   update_time DATETIME DEFAULT NULL COMMENT '更新时间',
   PRIMARY KEY (id),
   UNIQUE KEY uk_trade_no (trade_no),
+  UNIQUE KEY uk_merchant_trade_no (merchant_trade_no),
   KEY idx_dd_id (dd_id),
-  KEY idx_merchant_trade_no (merchant_trade_no)
+  KEY idx_dd_id_pay_status_id (dd_id, pay_status, id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OMG支付交易流水';
 
 -- 2026-07-29 OMG支付接入 - OMG退款记录表

+ 434 - 0
specs/016-omg-payment/frontend-integration.md

@@ -0,0 +1,434 @@
+# OMG(歐買尬/FunPoint)支付 — 前端接入文档
+
+**适用端**:客户 App(uni-app) | **后端分支**:`OmgPayController` 已完成 | **日期**:2026-08-06
+
+---
+
+## 0. 先搞懂一件事:OMG 没有「支付 App」
+
+OMG(欧买尬/FunPoint)是**网页托管式支付网关**(AIO = All-In-One 收银台),跟蓝新/绿界同类。它的设计就是**没有独立 App**——付款发生在 **OMG 的网页收银台**上。
+
+| 支付 | 有没有 App | 付款在哪发生 | 前端怎么触发 |
+|------|-----------|-------------|------------|
+| LINE Pay | 有 LINE App | 唤起 LINE App 内 | deep link 唤起 |
+| **OMG / FunPoint** | **没有 App** | **OMG 的网页收银台** | **form post 跳网页** |
+
+后端 `ChoosePayment=ALL` 已把 **信用卡 / Apple Pay / ATM / 超商 / AFTEE** 全聚合在一个收银台里,用户在那个网页上自己选方式付款。**前端不需要集成任何支付 SDK,只需要把用户「跳过去」再「接回来」。**
+
+---
+
+## 1. 整体流程(必读)
+
+```
+[App: 订单详情页 点"去支付"]
+        │
+        │  ① POST /pay/omg/create?orderid={ddId}   (Header: token)
+        ▼
+[后端] 校验订单 → 组参+签名 → 返回 form 字段(含 gatewayUrl)
+        │
+        │  返回 { data: { gatewayUrl, MerchantID, MerchantTradeNo, CheckMacValue, ... } }
+        ▼
+[App] ② 把 form 字段拼到 URL,打开 <web-view> 加载 omgPay.html
+        │
+        │  omgPay.html 自动 submit 隐藏表单 → 跳转
+        ▼
+[OMG 收银台网页]  用户选支付方式(信用卡/Apple Pay/ATM/超商)并付款
+        │
+        │  ③ 付款后 OMG 把 web-view 跳回 OrderResultURL
+        │     后端 /pay/omg/return 302 → 前端结果页?ddId=xxx
+        ▼
+[App: 支付结果页] 读 ddId → 查订单状态 → 展示结果
+
+   ─── 与此同时(服务端,前端无感)───
+   OMG POST 后端 /pay/omg/notify  → 验签+核销 → 订单 payStatus 置 1 + 推送
+   (订单状态以这条服务端回调为准,不要只信网页跳转)
+```
+
+> **关键原则**:网页跳回结果页 ≠ 一定支付成功。结果页必须**再查一次订单 `payStatus`** 来判断,因为服务端回调可能晚到几秒。
+
+---
+
+## 2. 前端要做的事(总览)
+
+| # | 事项 | 在哪做 |
+|---|------|--------|
+| 1 | 放一个 `omgPay.html` 到 `static/`,作为 web-view 收银台跳转页 | uni-app 工程 |
+| 2 | 发起支付:调 `/pay/omg/create` → 拼 URL → 打开 web-view | 发起支付页 |
+| 3 | 支付结果页:接收 `ddId` → 查状态 → 展示成功/失败/取号 | 支付结果页 |
+| 4 | ATM/超商取号展示页:调 `/pay/omg/paymentInfo/{orderid}` 展示虚帐/缴费码 | 取号页(可与结果页合并) |
+| 5 | (可选)取消订单退款:调 `/pay/omg/refund` | 订单取消逻辑 |
+| 6 | 告诉后端两个前端地址(见 §6 配置依赖) | 沟通 |
+| 7 | 所有面向用户文字走 i18n 4 语言 | zh/tw/en/vi |
+
+---
+
+## 3. 接口清单
+
+> baseURL:前端约定的后端网关地址(如 `https://api.xxx.com`),下面路径在其后拼接。
+> 所有需登录接口:**Header 必须带 `token`(用户 JWT)**。
+
+### 3.1 发起支付 — `POST /pay/omg/create`
+
+**用途**:生成一次 OMG 收银台跳转所需的全部参数。
+
+| 项 | 内容 |
+|----|------|
+| Method | `POST` |
+| Header | `token: <用户JWT>` |
+| 入参 | query:`orderid` = 订单号 ddId |
+| 鉴权 | 登录用户 + 必须是订单本人 |
+
+**请求示例**
+```
+POST {baseURL}/pay/omg/create?orderid=DD202608060001
+Header: token: eyJhbGciOi...
+```
+
+**成功返回**(`code=200`,取 `data`)
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "gatewayUrl": "https://payment.funpoint.com.tw/Cashier/AioCheckOut/V5",
+    "MerchantID": "3xxxxxx2",
+    "MerchantTradeNo": "OMG20260806143012xxx",
+    "MerchantTradeDate": "2026/08/06 14:30:12",
+    "PaymentType": "aio",
+    "TotalAmount": "350",
+    "TradeDesc": "food order DD202608060001",
+    "ItemName": "order DD202608060001",
+    "ReturnURL": "https://api.xxx.com/pay/omg/notify",
+    "ChoosePayment": "ALL",
+    "EncryptType": "1",
+    "InvoiceMark": "N",
+    "NeedExtraPaidInfo": "Y",
+    "OrderResultURL": "https://app.xxx.com/.../omgResult",
+    "PaymentInfoURL": "https://api.xxx.com/pay/omg/paymentInfo",
+    "CheckMacValue": "A1B2C3D4E5F6...(64位)"
+  }
+}
+```
+
+**常见错误返回**
+| code / msg | 含义 | 前端处理 |
+|-----------|------|---------|
+| 请先登录 | token 缺失/失效 | 跳登录 |
+| 无权操作该订单 | 非本人订单 | 提示即可 |
+| 订单已支付 | 已付过 | 刷新订单状态 |
+| 订单已取消,不可重新支付 | state=4 | 引导重下单 |
+| 该门店暂不支持线上支付 | 门店未开通 OMG | 提示「该门店暂不支持在线支付」 |
+| 请求过于频繁 | 1 秒内重复点 | 防抖 |
+
+> ⚠️ **绝对不能修改 `data` 里任何字段的值**(尤其 `CheckMacValue`)。任何改动都会导致 OMG 验签失败。前端只负责**原样透传**。
+
+---
+
+### 3.2 支付结果页跳转 — `GET/POST /pay/omg/return`(后端自动 302)
+
+**不是前端直接调的接口**。付款完成后 OMG 把网页跳到后端 `/pay/omg/return`,后端反查出 `ddId` 后 **302 重定向**到前端结果页:
+
+```
+{omg.order-result-url}?ddId=DD202608060001
+```
+
+前端只需:**注册一个结果页路由**接收 query 上的 `ddId`(见 §4.3)。`omg.order-result-url` 由后端配置(§6),需要前端把这个页面地址给后端。
+
+---
+
+### 3.3 ATM/超商取号查询 — `GET /pay/omg/paymentInfo/{orderid}`
+
+**用途**:延期支付(ATM/超商/AFTEE)取号后,展示虚拟帐号 / 缴费码 / 期限。即时支付(信用卡)也可用它查 `payStatus`。
+
+| 项 | 内容 |
+|----|------|
+| Method | `GET` |
+| Header | `token: <用户JWT>` |
+| 入参 | path:`orderid` = ddId |
+| 鉴权 | 登录用户 + 订单本人 |
+
+**请求示例**
+```
+GET {baseURL}/pay/omg/paymentInfo/DD202608060001
+Header: token: eyJ...
+```
+
+**返回**
+```json
+{
+  "code": 200,
+  "data": {
+    "payType": "ATM_FIRST",        // 付款方式(见 §5)
+    "amount": 350,
+    "payStatus": 0,                // 0未支付/1已支付/2失败/3已退款
+    "info": {                       // 取号信息(信用卡即时支付时 info 为空 {})
+      "BankCode": "807",
+      "vAccount": "99123450000012",
+      "ExpireDate": "2026/08/09",
+      "TradeNo": "..."
+    }
+  }
+}
+```
+
+`info` 字段说明(延期支付才有):
+
+| 付款方式 payType | info 里的关键字段 |
+|-----------------|------------------|
+| `ATM_*` | `BankCode`(银行代码) `vAccount`(虚拟帐号) `ExpireDate`(缴费期限) |
+| `CVS_*` | `PaymentNo`(缴费码) `ExpireDate`(期限) |
+| `BarcodeATM_*` | 同 CVS |
+| `AFTEE_AFTEE` | AFTEE 先享后付信息 |
+
+---
+
+### 3.4 退款(取消已支付订单)— `POST /pay/omg/refund`
+
+**用途**:用户取消已支付订单时退款(仅信用卡/Apple Pay 可自动退;ATM/超商需人工,接口会返回提示)。
+
+| 项 | 内容 |
+|----|------|
+| Method | `POST` |
+| Header | `token: <用户JWT>` |
+| 入参 | query:`orderid` = ddId |
+| 鉴权 | 订单本人 |
+
+**请求**
+```
+POST {baseURL}/pay/omg/refund?orderid=DD202608060001
+Header: token: eyJ...
+```
+
+**返回**
+| code=200 | 信用卡/Apple Pay 退刷成功,`pos_order.pay_status` 置 2 |
+| code=500 + 「该支付方式需人工在 OMG 后台退款」 | ATM/超商/BarcodeATM,无自动退款 API,后端已记工单 |
+
+---
+
+## 4. uni-app 参考实现
+
+> 以下为参考代码,需在 uni-app 工程内按实际路由/请求封装适配。
+
+### 4.1 收银台跳转页 `static/omgPay.html`
+
+放到 `static/` 目录(web-view 可加载本地文件)。它从 URL query 读取后端返回的所有字段,自动 submit 到 `gatewayUrl`。
+
+```html
+<!DOCTYPE html>
+<html lang="zh-TW">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+  <title>跳转支付中</title>
+  <style>
+    body { text-align:center; padding:60px 20px; font-family:-apple-system,sans-serif; color:#888;background:#fff }
+    .dots:after { content:'...'; animation:d 1s steps(1) infinite }
+    @keyframes d { 50%{content:''} }
+  </style>
+</head>
+<body>
+  <div id="tip">正在跳转至支付页面<span class="dots"></span></div>
+  <form id="payForm" method="post" action=""></form>
+  <script>
+    (function () {
+      var qs = location.search.substring(1);
+      if (!qs) { document.getElementById('tip').innerText = '参数缺失'; return; }
+      var params = {};
+      qs.split('&').forEach(function (kv) {
+        var i = kv.indexOf('=');
+        if (i > -1) params[decodeURIComponent(kv.slice(0, i))] = decodeURIComponent(kv.slice(i + 1));
+      });
+      var gateway = params.gatewayUrl;
+      if (!gateway) { document.getElementById('tip').innerText = '缺少 gatewayUrl'; return; }
+      var form = document.getElementById('payForm');
+      form.action = gateway;
+      Object.keys(params).forEach(function (k) {
+        if (k === 'gatewayUrl') return;          // gatewayUrl 仅作 action,不作表单字段
+        var el = document.createElement('input');
+        el.type = 'hidden'; el.name = k; el.value = params[k];
+        form.appendChild(el);
+      });
+      form.submit();
+    })();
+  </script>
+</body>
+</html>
+```
+
+### 4.2 发起支付页(点「去支付」)
+
+```js
+// 1. 调发起接口
+uni.request({
+  url: baseURL + '/pay/omg/create',
+  method: 'POST',
+  header: { token: uni.getStorageSync('token') },
+  data: { orderid: ddId },            // 也可用 ?orderid= 拼在 url 上
+  success: (res) => {
+    const d = res.data;
+    if (d.code !== 200) { uni.showToast({ title: d.msg, icon: 'none' }); return; }
+    goCheckout(d.data);               // data = form 字段
+  }
+});
+
+// 2. 拼 query 打开 web-view 收银台
+function goCheckout(form) {
+  // ⚠️ 每个值都要 encodeURIComponent,否则 / 空格 = 等字符会破坏 URL
+  const query = Object.keys(form)
+    .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(form[k]))
+    .join('&');
+  uni.navigateTo({
+    url: '/pages/pay/omgCheckout?pay=' + encodeURIComponent(query)
+  });
+}
+```
+
+```html
+<!-- pages/pay/omgCheckout.vue —— 只放一个 web-view -->
+<template>
+  <web-view :src="src"></web-view>
+</template>
+<script>
+export default {
+  data: () => ({ src: '' }),
+  onLoad(opt) {
+    // 把拼好的 query 透传给本地 omgPay.html
+    this.src = '/static/omgPay.html?' + decodeURIComponent(opt.pay);
+  }
+}
+</script>
+```
+
+> 注意:iOS 上 web-view 不可另开窗、收银台不可被 iframe 套——本方案是完整页 web-view,符合要求。
+
+### 4.3 支付结果页 `pages/pay/omgResult`
+
+后端 `/pay/omg/return` 会 302 到 `omgResult?ddId=xxx`。这个页面在 web-view 内被加载——加载后建议**立即跳回原生页**或直接渲染结果。最稳的做法:web-view 监听 URL 变化,命中 `omgResult` 就 `navigateBack` 回原生订单详情,由原生页轮询状态。
+
+```js
+// pages/pay/omgCheckout.vue 增强:监听收银台跳到结果页
+// uni-app web-view 可通过 @message 或 bindmessage 接收 H5 postMessage
+// 更简单:让 omgResult 页 postMessage 通知原生关闭 web-view
+```
+
+原生订单详情页(回来后)**轮询确认状态**:
+
+```js
+// 信用卡即时支付:轮询 paymentInfo 看是否 payStatus=1
+const poll = (ddId) => {
+  uni.request({
+    url: baseURL + '/pay/omg/paymentInfo/' + ddId,
+    header: { token: uni.getStorageSync('token') },
+    success: (res) => {
+      const d = res.data.data;
+      if (d.payStatus === 1)      { showSuccess(); }       // 支付成功
+      else if (d.payStatus === 2) { showFail(); }          // 失败
+      else if (d.payType && d.payType.startsWith('ATM') ||
+               d.payType && d.payType.startsWith('CVS'))   { showPickup(d.info); } // 延期→展示取号
+      else { setTimeout(() => poll(ddId), 2000); }         // 待回调,继续轮询
+    }
+  });
+};
+```
+
+> 轮询建议:每 2s 一次,最多 15 次(30s);超过仍未成功提示「支付结果确认中,请稍后在订单列表查看」。
+
+### 4.4 ATM/超商取号展示(延期支付)
+
+用户选 ATM/超商后,付款不是即时的——OMG 先给一个虚拟帐号/缴费码,用户在期限内自己去缴。前端展示来自 §3.3 的 `info`:
+
+```html
+<view>银行代码:{{ info.BankCode }}</view>
+<view>虚拟帐号:{{ info.vAccount }}</view>
+<view>缴费期限:{{ info.ExpireDate }}</view>
+<!-- 超商则展示 PaymentNo -->
+```
+
+并提示(i18n):「请在期限内完成缴费,缴费后订单将自动变为已支付」。
+
+---
+
+## 5. 状态码 / 付款方式速查
+
+### `payStatus`(订单 + 流水)
+| 值 | 含义 |
+|----|------|
+| 0 | 未支付 |
+| 1 | 已支付 |
+| 2 | 已退款(订单)/ 失败(流水) |
+| 3 | 已退款(流水) |
+
+### `payType`(OMG 回覆的付款方式)
+| payType | 名称 | 即时/延期 | 可自动退款 |
+|---------|------|----------|-----------|
+| `Credit_CreditCard` | 信用卡(含 Apple Pay / 银联) | 即时 | ✅ |
+| `BarcodeATM_CHINATRUST` | 超商快付代碼繳费 | 即时 | ❌ 人工 |
+| `ATM_FIRST/CHINATRUST/UBOT/KGI` | 各银行 ATM | **延期(取虚帐)** | ❌ 人工 |
+| `CVS_CVS/FAMILY/IBON/HILIFE` | 超商代碼繳费 | **延期(取码)** | ❌ 人工 |
+| `AFTEE_AFTEE` | AFTEE 先享后付 | 延期 | ❌ 人工 |
+
+> **即时支付**(信用卡/Apple Pay)→ 结果页轮询 `payStatus` 变 1 即成功。
+> **延期支付**(ATM/超商/AFTEE)→ 结果页展示取号信息,等用户线下缴款后 OMG 异步回调才置 1,无需前端轮询到成功。
+
+---
+
+## 6. 配置依赖(需前端 ↔ 后端约定)
+
+后端这几个 URL 来自 `application.yml` 的 `omg.*` 配置,**前端要把自己页面的线上地址给后端配置**:
+
+| 配置项 | 含义 | 需要的值(示例) |
+|--------|------|----------------|
+| `omg.order-result-url` | 付款后跳回的前端结果页 | `https://app.xxx.com/#/pages/pay/omgResult` |
+| `omg.payment-info-url` | ATM/超商取号回调(后端地址,**前端不用管**) | `https://api.xxx.com/pay/omg/paymentInfo` |
+| `omg.return-url` | 服务端结果回调(后端地址,**前端不用管**) | `https://api.xxx.com/pay/omg/notify` |
+
+> 前端只需要确认:**`omgResult` 页面的完整 URL**(含 hash 路由或 history 路由),交给后端填 `omg.order-result-url`。测试期可用测试域名。
+
+---
+
+## 7. 注意事项 / 易踩坑
+
+1. **不要修改任何 form 字段值**——尤其 `CheckMacValue`,改一个字符 OMG 验签就失败。
+2. **结果不能只信网页跳转**——OMG 网页跳回 ≠ 服务端回调已完成,必须轮询 `payStatus` 确认。
+3. **iOS web-view 不可另开窗**——收银台必须是完整页 web-view,不能 `window.open`、不能 iframe 嵌套。
+4. **query 传参必须 encodeURIComponent**——`MerchantTradeDate` 含 `/` 和空格,不编码会截断。
+5. **多次发起**——同一订单可重复发起(每次新 `MerchantTradeNo`),后端按 OMG 交易号 `tradeNo` 幂等,前端重复点「支付」不会重复扣款,但仍建议按钮防抖(后端已加 1s `@RepeatSubmit`)。
+6. **延期支付别轮询到成功**——ATM/超商是线下缴款,可能几小时甚至跨天,前端展示取号信息即可,不要无限轮询。
+7. **退款只对信用卡/Apple Pay 自动**——ATM/超商选了就退不了,接口会返回「需人工」,前端按提示展示。
+
+---
+
+## 8. i18n 提示(项目硬性要求)
+
+所有面向用户的文字必须走 `$t()`,4 语言(zh/tw/en/vi)同步添加,禁止硬编码中文。本功能涉及的文案 key 建议:
+
+| key | 中文 | 用途 |
+|-----|------|------|
+| `payOmg.goPay` | 去支付 | 发起按钮 |
+| `payOmg.redirecting` | 正在跳转至支付页面 | omgPay.html / 跳转中 |
+| `payOmg.success` | 支付成功 | 结果页 |
+| `payOmg.fail` | 支付失败 | 结果页 |
+| `payOmg.confirming` | 支付结果确认中 | 轮询中 |
+| `payOmg.bankCode` | 银行代码 | 取号展示 |
+| `payOmg.vAccount` | 虚拟帐号 | 取号展示 |
+| `payOmg.paymentNo` | 缴费代码 | 超商取号 |
+| `payOmg.expireDate` | 缴费期限 | 取号展示 |
+| `payOmg.atmTip` | 请在期限内完成缴费,缴费后订单将自动变为已支付 | 延期支付提示 |
+| `payOmg.storeNotSupported` | 该门店暂不支持在线支付 | 门店未开通 |
+| `payOmg.refundManual` | 该支付方式需人工退款,请联系客服 | 退款人工提示 |
+
+> key 必须用有意义英文(驼峰),加到 4 个语言文件**对应层级对象内**(参考项目 CLAUDE.md 的 i18n 规范)。
+
+---
+
+## 附:后端接口文件对照
+
+| 前端动作 | 后端方法 | 文件 |
+|---------|---------|------|
+| 发起支付 | `OmgPayController.create` | `app/pay/OmgPayController.java` |
+| 结果页 302 | `OmgPayController.returnCallback` | 同上 |
+| 取号查询 | `OmgPayController.getPaymentInfo` | 同上 |
+| 退款 | `OmgPayController.refund` | 同上 |
+| 服务端回调(前端无感) | `OmgPayController.notify` / `paymentInfoCallback` | 同上 |
+
+后端契约详见 `specs/016-omg-payment/contracts/api.md`。

+ 1 - 1
specs/016-omg-payment/quickstart.md

@@ -14,7 +14,7 @@
    - `omg.base-url=https://payment-stage.funpoint.com.tw`
    - `omg.return-url` / `omg.payment-info-url` 指向**公网可达**的本服务地址(本地用 ngrok/frp 穿透到 8082)。
 3. **凭证**:在盘合测试后台申请测试商店号,拿到 `MerchantID` / `HashKey` / `HashIV`。
-4. **签名自测**:运行 `OmgCheckMacValue.main`,应 PASS(用官方示例值复算得 `AA5842FD...CAF93266`)
+4. **签名自测**:运行 `OmgCheckMacValue.main`,应 PASS(ECPay 官方 MD5 向量锁定编码流水线);OMG SHA256 需使用实际商户凭证联调验签
 
 ---
 

+ 2 - 2
specs/016-omg-payment/research.md

@@ -32,7 +32,7 @@
 
 **与蓝新的本质区别**:蓝新是 AES-256-CBC 加密 TradeInfo + SHA256 TradeSha,回调需 AES 解密;**OMG 无 AES、无解密**,请求与回调都是「明文参数 + 单个 CheckMacValue」。实现更简单,故绝不复用 `NewebPayEncryptUtil`。
 
-**自测**:用官方示例 HashKey=`5294y06JbISpM5x9`、HashIV=`v77hoKGq4kWxNNIS` 的参数复算,应得 `AA5842FDA7E55ACEB7118D6353E9822CA6D6FF09A0D1FC129A879DD5CAF93266`(`OmgCheckMacValue.main` 自测)
+**自测**:仓库保留 ECPay 官方 MD5 向量锁定排序与 .NET URL 编码流水线;旧草稿中的 SHA256 期望值与其参数集不一致,已不再作为自测断言。OMG SHA256 应使用当前商户凭证在 stage/正式环境做端到端验签
 
 ---
 
@@ -40,7 +40,7 @@
 
 **Decision**:新建 `pos_order_omg_payment`(每笔 OMG 交易一条,按 `trade_no` = OMG `TradeNo` 幂等),**不复用** 011 的 `pos_order_payment`(复用即复用蓝新代码/数据,违背约束 0)。
 
-**字段**:`dd_id`、`merchant_trade_no`(商店交易编号 MerchantTradeNo,发起生成)、`trade_no`(OMG 交易编号 TradeNo,回调获得,幂等键,唯一索引)、`store_id`、`merchant_id`、`choose_payment`(发起 ALL)、`pay_type`(回调方式 Credit/ATM/CVS/ApplePay/BarcodeATM/AFTEE)、`amount`、`rtn_code`、`rtn_msg`、`pay_status`(0 未支付/1 已支付/2 失败/3 已退款)、`auth_code`、`pay_time`(PaymentDate)、`trade_date`、`callback_raw`、审计时间。唯一索引 `trade_no`。
+**字段**:`dd_id`、`merchant_trade_no`(商店交易编号 MerchantTradeNo,发起生成,唯一索引)、`trade_no`(OMG 交易编号 TradeNo,回调获得,幂等键,唯一索引)、`store_id`、`merchant_id`、`choose_payment`(发起 ALL)、`pay_type`(回调方式 Credit/ATM/CVS/ApplePay/BarcodeATM/AFTEE)、`amount`、`rtn_code`、`rtn_msg`、`pay_status`(0 未支付/1 已支付/2 失败/3 已退款/4 退款中)、`auth_code`、`pay_time`(PaymentDate)、`trade_date`、`callback_raw`、审计时间。
 
 **退款**:新建 `pos_order_omg_refund`(每次 DoAction 一行:`payment_id`/`trade_no`/`action`/`amount`/`rtn_code`/`rtn_msg`/`callback_raw`/`time`),与流水解耦,支持多次动作与对账。
 

+ 24 - 0
specs/016-omg-payment/tasks.md

@@ -143,6 +143,30 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 ---
 
+## Phase 9: User Story 6 - OMG 漏单补单(回调可靠性)(Priority: P2)
+
+**Goal**: 异步回调丢失/延迟不丢单。方案A 被动补单接口(前端结果页轮询仍未支付时主动查 OMG 真实状态补单)+ 方案B 定时兜底(用户付完即关 App 且回调丢失的极端场景)。
+
+**Source**: [callback-reconcile.md](callback-reconcile.md)(方案A/B 设计)
+
+**Independent Test**:
+- 方案A:测试卡付款成功但模拟不触发回调 → 订单 `payStatus=0` → 调 `POST /pay/omg/query` → 查 OMG `TradeStatus=1` → 补单 `markSuccess` + 订单 `payStatus=1`;回调+补单并发不重复(trade_no 幂等)
+- 方案B:构造 `pay_status=0` 流水(`create_time` 在窗口内)→ 等定时任务触发 → 自动补单;窗口外(>7天)/已取消订单不扫
+
+- [x] T042 [P] [US6] contracts/api.md:§B5 `POST /pay/omg/query` 由「可选/未实现」补全为正式契约(入参/鉴权/返回/TradeStatus 分支/幂等/自愈);新增 §B8 定时补单(@Scheduled+Redisson)说明
+- [x] T043 [US6] 抽取共享补单核销逻辑(`OmgPayController`):`applyPaidResult(...)`(notify 成功分支改调此,消除重复)+ `public reconcileByQuery(ddId, source)`(queryTrade→金额校验→applyPaidResult/markFail,含幂等与跨事务中断自愈),供 /query 与定时任务复用
+- [x] T044 [US6] 方案A:`OmgPayController` 新增 `POST /pay/omg/query`(@Anonymous @Auth,校验登录+订单本人+payType=7+未支付未取消)→ 调 `reconcileByQuery` → 返回 `{payStatus:0/1/2, reconciled}`;补单写 `orderLogHelper` 日志
+- [x] T045 [US6] 方案B 数据层:`PosOrderOmgPaymentMapper` 加 `selectLeakOrderDdIds(windowStart, graceCutoff, batchSize)`(每订单取最新一笔 `pay_status=0`,INNER JOIN pos_order 排除 state=4)+ XML;`IPosOrderOmgPaymentService` 加 `scanLeakOrders(...)`
+- [x] T046 [US6] 方案B 任务:新建 `ruoyi-admin/.../app/task/OmgReconcileTask.java`(@Scheduled fixedDelay 默认3分钟 + Redisson 分布式锁 `lock:omg:reconcile`,多实例单节点执行)→ 扫漏单逐笔调 `reconcileByQuery(ddId,"scheduled")`;`RuoYiApplication` 加 `@EnableScheduling`
+- [x] T047 [P] [US6] 配置:`application.yml` 加 `omg.reconcile.*`(fixed-delay-ms 默认180000 / window-hours 默认168=7天覆盖延期 ExpireDate / grace-minutes 默认2 / batch-size / lock-wait-seconds / lock-lease-seconds)
+- [x] T048 [US6] DDL:`updatesql/sql.md` 追加 `pos_order_omg_payment` 扫描索引 `idx_pay_status_create(pay_status, create_time)`(窗口扫描用,不直接执行)
+- [ ] T049 [US6] 方案A/B 测试:模拟回调丢失→/query 补单、回调+补单并发不重复、定时任务扫描补单(依赖测试环境 stage+测试卡,随 T040 端到端阶段做)
+- [ ] T050 [US6] 前端配合(frontend-integration.md §4.3):客户 uni-app 结果页轮询 >30s 仍 `payStatus=0` 时改调 /query —— ⏸️ 推迟:客户 uni-app 不在当前工作区(同 T017/T024)
+
+**Checkpoint**: 回调丢失不丢单;方案A 用户侧自愈、方案B 离线兜底,A+B 覆盖全部漏单场景
+
+---
+
 ## Dependencies & Execution Order
 
 ### Phase 依赖

+ 55 - 7
updatesql/sql.md

@@ -503,7 +503,8 @@ CREATE TABLE pos_store_omg (
   create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人',
   update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人',
   PRIMARY KEY (id),
-  UNIQUE KEY uk_store_id (store_id)
+  UNIQUE KEY uk_store_id (store_id),
+  UNIQUE KEY uk_merchant_id (merchant_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店OMG(歐買尬)支付凭证';
 
 -- OMG 支付交易流水表(trade_no 幂等键,MySQL 唯一索引允许多个 NULL)
@@ -512,14 +513,14 @@ CREATE TABLE pos_order_omg_payment (
   dd_id VARCHAR(64) NOT NULL COMMENT '系统订单号(关联 pos_order.dd_id)',
   merchant_trade_no VARCHAR(20) NOT NULL COMMENT '商店交易编号 MerchantTradeNo',
   trade_no VARCHAR(20) DEFAULT NULL COMMENT 'OMG交易编号 TradeNo(幂等键)',
-  store_id BIGINT DEFAULT NULL COMMENT '门店ID',
-  merchant_id VARCHAR(10) DEFAULT NULL COMMENT 'OMG商店代号',
+  store_id BIGINT NOT NULL COMMENT '门店ID',
+  merchant_id VARCHAR(10) NOT NULL COMMENT 'OMG商店代号',
   choose_payment VARCHAR(20) DEFAULT 'ALL' COMMENT '发起方式',
-  pay_type VARCHAR(20) DEFAULT NULL COMMENT '回覆PaymentType:Credit_CreditCard/ATM_*/CVS_*/AFTEE_AFTEE/BarcodeATM_CHINATRUST',
-  amount INT DEFAULT NULL COMMENT '交易金额(整数元)',
+  pay_type VARCHAR(32) DEFAULT NULL COMMENT '回覆PaymentType:Credit_CreditCard/ATM_*/CVS_*/AFTEE_AFTEE/BarcodeATM_CHINATRUST',
+  amount INT NOT NULL COMMENT '交易金额(整数元)',
   rtn_code INT DEFAULT NULL COMMENT '回调RtnCode(1=成功)',
   rtn_msg VARCHAR(200) DEFAULT NULL COMMENT '回调RtnMsg',
-  pay_status INT NOT NULL DEFAULT 0 COMMENT '0未支付/1已支付/2失败/3已退款',
+  pay_status INT NOT NULL DEFAULT 0 COMMENT '0未支付/1已支付/2失败/3已退款/4退款中',
   auth_code VARCHAR(20) DEFAULT NULL COMMENT '授权码',
   pay_time DATETIME DEFAULT NULL COMMENT '支付完成时间',
   trade_date DATETIME DEFAULT NULL COMMENT '订单创建时间',
@@ -528,8 +529,9 @@ CREATE TABLE pos_order_omg_payment (
   update_time DATETIME DEFAULT NULL COMMENT '更新时间',
   PRIMARY KEY (id),
   UNIQUE KEY uk_trade_no (trade_no),
+  UNIQUE KEY uk_merchant_trade_no (merchant_trade_no),
   KEY idx_dd_id (dd_id),
-  KEY idx_merchant_trade_no (merchant_trade_no)
+  KEY idx_dd_id_pay_status_id (dd_id, pay_status, id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OMG支付交易流水';
 
 -- OMG 退款记录表
@@ -550,6 +552,41 @@ CREATE TABLE pos_order_omg_refund (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OMG退款记录';
 ```
 
+## 2026-08-06 OMG支付流水约束与退款状态修复(已部署016表的环境执行)
+
+```sql
+-- 执行 ALTER 前必须先确认以下查询均返回 0 行;发现重复或空关联时先人工核对流水,禁止直接删除。
+SELECT merchant_trade_no, COUNT(*) AS duplicate_count
+FROM pos_order_omg_payment
+GROUP BY merchant_trade_no
+HAVING COUNT(*) > 1;
+
+SELECT merchant_id, COUNT(*) AS duplicate_count
+FROM pos_store_omg
+WHERE merchant_id IS NOT NULL
+GROUP BY merchant_id
+HAVING COUNT(*) > 1;
+
+SELECT id, dd_id, store_id, merchant_id, amount
+FROM pos_order_omg_payment
+WHERE store_id IS NULL OR merchant_id IS NULL OR amount IS NULL;
+
+-- MerchantTradeNo 是回调关联键,必须唯一;扩大 PaymentType 以容纳 BarcodeATM_CHINATRUST。
+ALTER TABLE pos_order_omg_payment
+  DROP INDEX idx_merchant_trade_no,
+  ADD UNIQUE KEY uk_merchant_trade_no (merchant_trade_no),
+  ADD KEY idx_dd_id_pay_status_id (dd_id, pay_status, id),
+  MODIFY COLUMN store_id BIGINT NOT NULL COMMENT '门店ID',
+  MODIFY COLUMN merchant_id VARCHAR(10) NOT NULL COMMENT 'OMG商店代号',
+  MODIFY COLUMN pay_type VARCHAR(32) DEFAULT NULL COMMENT '回覆PaymentType',
+  MODIFY COLUMN amount INT NOT NULL COMMENT '交易金额(整数元)',
+  MODIFY COLUMN pay_status INT NOT NULL DEFAULT 0 COMMENT '0未支付/1已支付/2失败/3已退款/4退款中';
+
+-- MerchantID 由回调反查唯一门店凭证,禁止重复配置到多个门店。
+ALTER TABLE pos_store_omg
+  ADD UNIQUE KEY uk_merchant_id (merchant_id);
+```
+
 ## 2026-07-29 OMG支付接入 - 门店支付配置菜单与权限(016-omg-payment US5,Tab页:OMG可用 / LINE Pay预留)
 
 ```sql
@@ -580,3 +617,14 @@ INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_typ
 UPDATE pos_order_invoice SET carrier_type = NULL, carrier_num = NULL
 WHERE invoice_choice = 'MEMBER' AND carrier_type = '2';
 ```
+
+
+## 2026-08-07 OMG支付漏单补单 - 流水扫描索引(016-omg-payment US6 方案B,定时补单扫描用)
+
+```sql
+-- 背景:OmgReconcileTask 定时扫描 pay_status=0 且 create_time 在补单窗口内的漏单。
+--      现有 idx_dd_id_pay_status_id(dd_id,pay_status,id) 以 dd_id 打头,不适合按 pay_status+create_time 的全局窗口扫描。
+--      加此复合索引提速扫描,不直接执行,由开发者统一手动执行。
+ALTER TABLE pos_order_omg_payment ADD INDEX idx_pay_status_create (pay_status, create_time);
+```
+