Преглед изворни кода

fix(omg-pay): 修复已付订单被查询判未付 + 补 queryTrade 诊断日志

问题B:订单流水在 OMG 后台已授权扣款,但 /pay/omg/query 返回未付,
导致永不自动补单。最可能根因是 parseKvResponse 对尾随 & 的空段严拒
抛异常,被 reconcileByQuery 的 catch(Exception) 吞成 {0,0}。

- parseKvResponse 放宽尾随/连续 & 的空段(跳过不抛);重复键/空key/无= 仍抛。
  空段不进 map,CheckMacValue 验签零影响。
- queryTrade 解析/验签失败时 log.error 原始响应前500字节再 rethrow。
- reconcileByQuery 三个静默分支补结构化日志;catch 落 ipn_log(type=omg_query_error)
  供定位 id=26 类问题根因。
- OmgPayTest 移除尾随&断言,新增容忍用例。

不动状态机(is_active/pay_status/DDL),账本重构留待问题B线上验证后。

Co-Authored-By: Claude <noreply@anthropic.com>
qmj пре 2 недеља
родитељ
комит
7a8e248839

+ 16 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java

@@ -786,7 +786,8 @@ public class OmgPayController extends BaseController {
 
         PosStoreOmg cred = storeOmgService.getCredentialByMerchantIdAndStoreId(payment.getMerchantId(), payment.getStoreId());
         if (cred == null) {
-            log.warn("[OMG-{}] 门店凭证不可用: ddId={}", source, ddId);
+            log.error("[OMG-{}] 门店凭证不可用: ddId={}, merchantId={}, storeId={}, mtcn={}",
+                    source, ddId, payment.getMerchantId(), payment.getStoreId(), payment.getMerchantTradeNo());
             return new int[]{0, 0};
         }
         OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
@@ -795,7 +796,18 @@ public class OmgPayController extends BaseController {
         try {
             resp = omgPay.queryTrade(baseUrl, cfg, payment.getMerchantTradeNo());
         } catch (Exception e) {
-            log.warn("[OMG-{}] queryTrade 失败(下轮重试): ddId={}, err={}", source, ddId, e.getMessage());
+            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");
@@ -828,6 +840,8 @@ public class OmgPayController extends BaseController {
             return new int[]{2, 0};
         }
         // TradeStatus=0 未付(延期支付付款前常态)或其他未知值:保持未支付,定时任务下轮再查
+        log.warn("[OMG-{}] queryTrade返回未付/未知状态: ddId={}, mtcn={}, TradeStatus={}",
+                source, ddId, payment.getMerchantTradeNo(), tradeStatus);
         return new int[]{0, 0};
     }
 

+ 26 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgPay.java

@@ -94,8 +94,19 @@ public class OmgPay {
         params.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
         params.put("CheckMacValue", OmgCheckMacValue.generate(params, cfg.getHashKey(), cfg.getHashIv()));
         String resp = postForm(endpoint(baseUrl, URL_QUERY_TRADE_INFO), params);
-        Map<String, String> result = parseKvResponse(resp);
-        verifyResponse(result, cfg, true);
+        Map<String, String> result;
+        try {
+            result = parseKvResponse(resp);
+        } catch (RuntimeException e) {
+            log.error("[OMG] queryTrade 解析响应失败 MerchantTradeNo={} resp={}", merchantTradeNo, truncate(resp, 500), e);
+            throw e;
+        }
+        try {
+            verifyResponse(result, cfg, true);
+        } catch (RuntimeException e) {
+            log.error("[OMG] queryTrade 验签失败 MerchantTradeNo={} resp={}", merchantTradeNo, truncate(resp, 500), e);
+            throw e;
+        }
         verifyCorrelation(result, "MerchantID", cfg.getMerchantId());
         verifyCorrelation(result, "MerchantTradeNo", merchantTradeNo);
         return result;
@@ -178,6 +189,11 @@ public class OmgPay {
             throw new IllegalArgumentException("OMG response is blank");
         }
         for (String pair : resp.split("&", -1)) {
+            if (pair.isEmpty()) {
+                // 尾随 & 或连续 && 产生的空段是 OMG 真实响应常态,跳过;
+                // 空段不含 key=value,不进入 map,不影响 CheckMacValue 验签。
+                continue;
+            }
             int idx = pair.indexOf('=');
             if (idx <= 0) {
                 throw new IllegalArgumentException("Malformed OMG response field");
@@ -243,4 +259,12 @@ public class OmgPay {
             throw new IllegalArgumentException(name + " is required");
         }
     }
+
+    /** 截断响应文本用于诊断日志(null 安全)。 */
+    private static String truncate(String s, int max) {
+        if (s == null) {
+            return null;
+        }
+        return s.length() <= max ? s : s.substring(0, max);
+    }
 }

+ 13 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/utils/omg/OmgPayTest.java

@@ -29,8 +29,7 @@ class OmgPayTest {
                 () -> omgPay.parseKvResponse("RtnCode=1&broken"));
         assertThrows(IllegalArgumentException.class,
                 () -> omgPay.parseKvResponse("=value"));
-        assertThrows(IllegalArgumentException.class,
-                () -> omgPay.parseKvResponse("RtnCode=1&"));
+        // 尾随 & 的空段不再抛(parseKvResponse 已放宽),见 toleratesTrailingAmpersandAndEmptyValue
     }
 
     @Test
@@ -45,4 +44,16 @@ class OmgPayTest {
 
         assertThrows(IllegalArgumentException.class, () -> omgPay.parseKvResponse(response.toString()));
     }
+
+    @Test
+    void toleratesTrailingAmpersandAndEmptyValue() {
+        // OMG 真实响应常带尾随 & 与空值字段(如 PaymentDate=),parseKvResponse 放宽后不应抛
+        Map<String, String> result = omgPay.parseKvResponse(
+                "RtnCode=1&PaymentDate=&RtnMsg=OK&");
+
+        assertEquals(3, result.size());
+        assertEquals("1", result.get("RtnCode"));
+        assertEquals("", result.get("PaymentDate"));
+        assertEquals("OK", result.get("RtnMsg"));
+    }
 }