Bläddra i källkod

fix(omg-pay): T054 reconcileByQuery 两段式遍历全量未付(补 id=26 关键)

- ① getLatestPaidByDdId(pay_status IN(1,3,4))自愈:已付但订单未核销 → 补推
- ② listUnpaidByDdId 遍历全量未付(pay_status=0,含历史行)queryTrade:
  TradeStatus=1 → applyPaidResult + break(补较早 MTN 如 id=26)
  10200095+未取号(trade_no IS NULL)→ markFail(0→2,CAS 允许 2→1 复活)
  10200095+已取号 → no-op(可能 OMG 延迟建案/结算滞后)
  TradeStatus=0/未知 → no-op
- queryTrade HTTP 403(OMG MerchantID 限流)→ 停止遍历(过渡,T055 令牌桶节流完整前)

补 id=26(较早 MTN 已付,旧 getLatestByDdId 只取最新漏它)的关键。
T055(三道节流闸)未做,多 MTN 连发可能 403 → 多轮补单(慢但最终补)。

Co-Authored-By: Claude <noreply@anthropic.com>
qmj 2 veckor sedan
förälder
incheckning
11532017df

+ 82 - 71
ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java

@@ -757,92 +757,103 @@ public class OmgPayController extends BaseController {
      * @param source 调用来源标记("query" 被动补单 / "scheduled" 定时补单),仅用于日志
      * @return {@code int[2]} = {payStatus, reconciled}:payStatus 0未付/1已付/2失败;reconciled 1=本次触发补单核销
      */
-    public int[] reconcileByQuery(String ddId, String source) {
+        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) {
+
+        // ① 自愈:流水已付(pay_status IN(1,3,4))但订单未核销(跨事务中断残留)→ 补推订单状态
+        PosOrderOmgPayment paid = paymentService.getLatestPaidByDdId(ddId);
+        if (paid != null) {
+            boolean isPaid = Integer.valueOf(1).equals(paid.getPayStatus());
+            if (isPaid && (order.getPayStatus() == null || order.getPayStatus() == 0L)) {
                 handlePaymentSuccess(order);
             }
-            return new int[]{1, 0};
-        }
-        if (ps != null && ps != 0) {
-            // 2失败/3已退/4退款中:终态,不再查询
-            return new int[]{ps == 2 ? 2 : 0, 0};
+            return new int[]{isPaid ? 1 : 0, 0};
         }
 
-        PosStoreOmg cred = storeOmgService.getCredentialByMerchantIdAndStoreId(payment.getMerchantId(), payment.getStoreId());
-        if (cred == null) {
-            log.error("[OMG-{}] 门店凭证不可用: ddId={}, merchantId={}, storeId={}, mtcn={}",
-                    source, ddId, payment.getMerchantId(), payment.getStoreId(), payment.getMerchantTradeNo());
+        // ② 遍历全量未付(pay_status=0,含历史行)queryTrade,任一已付即补单核销(补较早 MTN 如 id=26)
+        List<PosOrderOmgPayment> unpaid = paymentService.listUnpaidByDdId(ddId);
+        if (unpaid == null || unpaid.isEmpty()) {
             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.error("[OMG-{}] queryTrade 失败(下轮重试): ddId={}, mtcn={}, err={}: {}",
-                    source, ddId, payment.getMerchantTradeNo(), e.getClass().getName(), e.getMessage());
-            // 落库诊断:原始响应字符串已由 OmgPay 层 log.error 记录,此处记结构化异常摘要,供定位 id=26 类问题
-            Map<String, String> errLog = new LinkedHashMap<>();
-            errLog.put("ddId", ddId);
-            errLog.put("mtcn", payment.getMerchantTradeNo());
-            errLog.put("source", source);
-            errLog.put("error", e.getClass().getName() + ": " + e.getMessage());
-            IpnLog ipnLog = new IpnLog();
-            ipnLog.setType("omg_query_error");
-            ipnLog.setIpnLog(JSON.toJSONString(errLog));
-            ipnLogService.insertIpnLog(ipnLog);
-            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[]{0, 0};
+        int lastStatus = 0;
+        for (PosOrderOmgPayment p : unpaid) {
+            PosStoreOmg cred = storeOmgService.getCredentialByMerchantIdAndStoreId(p.getMerchantId(), p.getStoreId());
+            if (cred == null) {
+                log.error("[OMG-{}] 门店凭证不可用: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo());
+                continue;
             }
-            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[]{0, 0};
+            OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
+            Map<String, String> resp;
+            try {
+                resp = omgPay.queryTrade(baseUrl, cfg, p.getMerchantTradeNo());
+            } catch (Exception e) {
+                log.error("[OMG-{}] queryTrade 失败(下轮重试): ddId={}, mtcn={}, err={}: {}",
+                        source, ddId, p.getMerchantTradeNo(), e.getClass().getName(), e.getMessage());
+                Map<String, String> errLog = new LinkedHashMap<>();
+                errLog.put("ddId", ddId);
+                errLog.put("mtcn", p.getMerchantTradeNo());
+                errLog.put("source", source);
+                errLog.put("error", e.getClass().getName() + ": " + e.getMessage());
+                IpnLog ipnLog = new IpnLog();
+                ipnLog.setType("omg_query_error");
+                ipnLog.setIpnLog(JSON.toJSONString(errLog));
+                ipnLogService.insertIpnLog(ipnLog);
+                // OMG 按 MerchantID 限流(HTTP 403)时停止遍历,避免连发罚(T055 令牌桶节流完整前的过渡)
+                if (e.getMessage() != null && e.getMessage().contains("HTTP 403")) {
+                    log.warn("[OMG-{}] queryTrade 触发 OMG 403 限流,停止本单遍历(下轮重试): ddId={}", source, ddId);
+                    return new int[]{lastStatus, 0};
+                }
+                continue;
+            }
+            String tradeStatus = resp.get("TradeStatus");
+            if ("1".equals(tradeStatus)) {
+                String tradeNo = resp.get("TradeNo");
+                String paymentType = resp.get("PaymentType");
+                int respAmt = toInt(resp.get("TradeAmt"), -1);
+                if (tradeNo == null || tradeNo.isEmpty()
+                        || p.getAmount() == null || respAmt != p.getAmount()
+                        || order.getAmount() == null || order.getAmount().intValue() != p.getAmount()) {
+                    log.error("[OMG-{}] 补单金额/字段校验失败: ddId={}, mtcn={}, paymentAmt={}, orderAmt={}, queryAmt={}, tradeNo={}",
+                            source, ddId, p.getMerchantTradeNo(), p.getAmount(), order.getAmount(), respAmt, tradeNo);
+                    orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单金额/字段不符,需人工核对");
+                    continue;
+                }
+                Date payTime = parsePayTime(resp.get("PaymentDate"));
+                Date tradeDate = parsePayTime(resp.get("TradeDate"));
+                if (payTime == null || tradeDate == null) {
+                    log.warn("[OMG-{}] 补单响应日期非法: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo());
+                    continue;
+                }
+                applyPaidResult(p, order, tradeNo, paymentType, 1, resp.get("RtnMsg"),
+                        null, payTime, tradeDate, auditJson(resp));
+                orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单成功");
+                log.info("[OMG-{}] 补单成功: ddId={}, mtcn={}, tradeNo={}", source, ddId, p.getMerchantTradeNo(), tradeNo);
+                return new int[]{1, 1};
+            } else if ("10200095".equals(tradeStatus)) {
+                String tradeNo = resp.get("TradeNo");
+                if (tradeNo == null || tradeNo.isEmpty()) {
+                    // 从未与 OMG 建立交易(未取号)→ markFail(0→2,CAS 允许 2→1 复活)
+                    paymentService.markFail(p.getId(), null, "OMG查询返回失败(10200095)", auditJson(resp));
+                    orderLogHelper.logSync(ddId, 0, null, "系统", "OMG查询补单:交易失败(10200095),mtcn=" + p.getMerchantTradeNo());
+                    lastStatus = 2;
+                } else {
+                    // 已取号(可能 OMG 延迟建案/结算滞后)→ 不标失败,下轮再查
+                    log.warn("[OMG-{}] queryTrade 返回 10200095 但已取号(tradeNo={}),不标失败: ddId={}, mtcn={}",
+                            source, tradeNo, ddId, p.getMerchantTradeNo());
+                }
+            } else {
+                // TradeStatus=0 未付(延期支付付款前常态)或未知 → no-op,定时任务下轮再查
+                log.warn("[OMG-{}] queryTrade 返回未付/未知: ddId={}, mtcn={}, TradeStatus={}",
+                        source, ddId, p.getMerchantTradeNo(), tradeStatus);
             }
-            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 未付(延期支付付款前常态)或其他未知值:保持未支付,定时任务下轮再查
-        log.warn("[OMG-{}] queryTrade返回未付/未知状态: ddId={}, mtcn={}, TradeStatus={}",
-                source, ddId, payment.getMerchantTradeNo(), tradeStatus);
-        return new int[]{0, 0};
+        }
+        return new int[]{lastStatus, 0};
     }
 
     /**

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

@@ -181,7 +181,7 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 - [x] T051 [P] **前置:parseKvResponse 重复键放宽**(`ruoyi-admin/.../utils/omg/OmgPay.java`):重复键 last-wins 不抛(用同一份 Map 重算 CheckMacValue 验签,避 kill-shot ⑥ 解析放宽与验签打架);仅整段无 `=` 或缺 CheckMacValue 才抛;`OmgPayTest` 补重复键用例。S 依赖 queryTrade 做换号/补单安全网,重复键仍抛会让安全网失效。
 - [x] T052 **collation 对齐执行确认**:执行 `updatesql/sql.md` 2026-08-12 节(pos_order_omg_payment/pos_order_omg_refund/pos_store_omg CONVERT 到 utf8mb4_unicode_ci);执行后 `selectLeakOrderDdIds` JOIN 不再报 Illegal mix of collations。
 - [x] T053 **reconcile 扫描改全量未付**(`PosOrderOmgPaymentMapper.xml` + Service):`selectLeakOrderDdIds` 改 `EXISTS(任意 pay_status=0 行)` 去重 ddId(不再 MAX(id) 取一条);新增 `listUnpaidByDdId`(全量 pay_status=0,is_active 不限);`selectLatestPaidByDdId` 扩 `IN(1,3,4)`(修退款中/已退返 null);新增 `selectLatestRefundableByDdId`(pay_status IN(1,3,4))、`existsPaidByDdId`。
-- [ ] T054 **reconcileByQuery 两段式**(`OmgPayController.reconcileByQuery`):① 先 selectLatestPaidByDdId(pay_status=1 且 order.payStatus=0 → handlePaymentSuccess 自愈,L774 不能变死代码);② 否则 listUnpaidByDdId 遍历全量未付行 queryTrade:TradeStatus=1 applyPaidResult break / 0 no-op / 10200095 且 trade_no IS NULL markFail / 10200095 且 trade_no 非空 no-op。
+- [x] T054 **reconcileByQuery 两段式**(`OmgPayController.reconcileByQuery`):① 先 selectLatestPaidByDdId(pay_status=1 且 order.payStatus=0 → handlePaymentSuccess 自愈,L774 不能变死代码);② 否则 listUnpaidByDdId 遍历全量未付行 queryTrade:TradeStatus=1 applyPaidResult break / 0 no-op / 10200095 且 trade_no IS NULL markFail / 10200095 且 trade_no 非空 no-op。
 - [ ] T055 **三道节流闸(防 OMG 403)**(新建 queryTrade 节流组件 `ruoyi-admin/.../utils/omg/OmgQueryThrottle.java` + Redis):首查跳过 create_time<NOW()-INTERVAL 40 MINUTE;per-MerchantID 令牌桶 `omg:qt:token:{merchantId}`(1token/3s,burst1)acquire 失败 skip 本行本轮;per-ddId TTL(`omg:reconcile:dd:{ddId}` 180s / `omg:query:dd:{ddId}` 60s)。reconcileByQuery 与 /query 接入。
 - [ ] T056 **DDL 追加式 S 字段**(写 `updatesql/sql.md`,不直接执行):`pos_order_omg_payment` + `expire_date DATETIME NULL`(仅 paymentInfo 回调写,create 不猜)+ `is_active TINYINT NOT NULL DEFAULT 1` + 生成列 `active_dd_id=IF(is_active=1 AND pay_status=0,dd_id,NULL) VIRTUAL` + `UNIQUE uk_omg_active_dd(active_dd_id)`(需 MySQL 5.7.6+,**生产版本待确认**;不够则退化 `idx_omg_dd_active(dd_id,is_active,pay_status)`+app 锁+CAS);历史回填(每 ddId 最新 pay_status=0 置 is_active=1,其余 0;终态行 0;**绝不回填 expire_date**)。
 - [ ] T057 **实体 + mapper**(`PosOrderOmgPayment.java` + Mapper/XML):+ isActive/expireDate 字段(activeDdId 生成列只读可不映射);加 `selectActiveForReuse(ddId,freshMin)`(WHERE dd_id AND is_active=1 AND pay_status=0 AND trade_no IS NULL AND create_time>=NOW()-INTERVAL freshMin MINUTE ORDER BY id DESC LIMIT 1)、`markActiveHistorical(ddId)`(CAS UPDATE is_active=0 WHERE dd_id AND is_active=1 AND pay_status=0,不动 pay_status)、`markPaymentInfoIfOpen` 扩 expire_date 解析;**刻意不提供 updateMerchantTradeNo/updateTradeNo**(从能力杜绝换号丢钱)。