Bläddra i källkod

fix(omg-pay): T055 queryTrade 三道节流闸 + T059 读侧迁移(OmgPayController 部分)

T055 防 OMG 按 MerchantID HTTP 403 自残(§6.4【C】):
- 新增 OmgQueryThrottle:三闸全用 Redis SET NX EX(原子,无锁竞争)
  · 闸1 首查延迟:下单 40min 内不查(否则 403 罚 30min);按 OMG 官方约束跳"新"行
    (task 文本 create_time<NOW()-40min 方向相反,此处按 OMG 约束实现跳新行)
  · 闸2 per-MerchantID 令牌桶:1token/3s burst1,非阻塞
  · 闸3 per-ddId 扫描间隔:/query 60s、定时 180s
- reconcileByQuery 接入:闸3 在①自愈后②遍历前(自愈不节流);闸1+闸2 在 queryTrade 前

T059 读侧迁移(kill-shot ④,读对行)— OmgPayController 4 处:
- 取号展示:可退款行优先,回落最新行(未付款仍看 ATM 虚帐)
- refundOrderOutcome / markRefunding 重读 / confirmManualRefundOutcome → getLatestRefundableByDdId(pay_status IN(1,3,4))
- (OrderLifecycleService 的 validateOmg*/buildContext 3 处与 LINE Pay WIP 共 hunk 缠结,随 LINE Pay 一并提交)

参数 §9 待 OMG 官方确认(yml omg.query-throttle.*,默认 40/3/60/180,缺省即用)。

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

+ 27 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java

@@ -13,6 +13,7 @@ import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.app.utils.omg.OmgCheckMacValue;
 import com.ruoyi.app.utils.omg.OmgPay;
 import com.ruoyi.app.utils.omg.OmgPayConfig;
+import com.ruoyi.app.utils.omg.OmgQueryThrottle;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.annotation.RepeatSubmit;
 import com.ruoyi.common.core.controller.BaseController;
@@ -102,6 +103,8 @@ public class OmgPayController extends BaseController {
     private OrderLifecycleService orderLifecycleService;
     @Autowired
     private PaymentCreateGuardService paymentCreateGuardService;
+    @Autowired
+    private OmgQueryThrottle omgQueryThrottle;
 
     @Value("${omg.base-url}")
     private String baseUrl;
@@ -526,7 +529,11 @@ public class OmgPayController extends BaseController {
         if (order == null || order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
             return error("无权操作该订单");
         }
-        PosOrderOmgPayment p = paymentService.getLatestByDdId(orderid);
+        // 优先取已付/退款行(展示回执);无则取最新行(未付款场景展示 ATM/超商虚帐)
+        PosOrderOmgPayment p = paymentService.getLatestRefundableByDdId(orderid);
+        if (p == null) {
+            p = paymentService.getLatestByDdId(orderid);
+        }
         if (p == null) {
             return error("无支付记录");
         }
@@ -607,7 +614,7 @@ public class OmgPayController extends BaseController {
             return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "订单未支付,无需退款");
         }
         String ddId = String.valueOf(order.getDdId());
-        PosOrderOmgPayment payment = paymentService.getLatestByDdId(ddId);
+        PosOrderOmgPayment payment = paymentService.getLatestRefundableByDdId(ddId);
         if (payment == null) {
             return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无 OMG 支付流水");
         }
@@ -649,7 +656,7 @@ public class OmgPayController extends BaseController {
         }
 
         if (paymentService.markRefunding(payment.getId()) == 0) {
-            PosOrderOmgPayment latest = paymentService.getLatestByDdId(ddId);
+            PosOrderOmgPayment latest = paymentService.getLatestRefundableByDdId(ddId);
             if (latest != null && Integer.valueOf(3).equals(latest.getPayStatus())) {
                 return OmgRefundOutcome.of(OmgRefundOutcome.Status.IDEMPOTENT, "OMG 已完成退款");
             }
@@ -703,7 +710,7 @@ public class OmgPayController extends BaseController {
             return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "该订单非 OMG 支付");
         }
         String ddId = String.valueOf(order.getDdId());
-        PosOrderOmgPayment payment = paymentService.getLatestByDdId(ddId);
+        PosOrderOmgPayment payment = paymentService.getLatestRefundableByDdId(ddId);
         if (payment == null || "Credit_CreditCard".equals(payment.getPayType())) {
             return OmgRefundOutcome.of(OmgRefundOutcome.Status.FAILED, "无可确认的 OMG 人工退款待办");
         }
@@ -813,6 +820,12 @@ public class OmgPayController extends BaseController {
             return new int[]{isPaid ? 1 : 0, 0};
         }
 
+        // 闸3:per-ddId 扫描间隔节流(/query 60s / 定时 180s),挡前端轮询放大、防 OMG 403 自残(T055)
+        if (!omgQueryThrottle.acquireDdIdSlot(ddId, source)) {
+            log.debug("[OMG-{}] per-ddId 节流,跳过本轮 OMG 查询: ddId={}", source, ddId);
+            return new int[]{order.getPayStatus() == null ? 0 : order.getPayStatus().intValue(), 0};
+        }
+
         // ② 遍历全量未付(pay_status=0,含历史行)queryTrade,任一已付即补单核销(补较早 MTN 如 id=26)
         List<PosOrderOmgPayment> unpaid = paymentService.listUnpaidByDdId(ddId);
         if (unpaid == null || unpaid.isEmpty()) {
@@ -825,6 +838,16 @@ public class OmgPayController extends BaseController {
                 log.error("[OMG-{}] 门店凭证不可用: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo());
                 continue;
             }
+            // 闸1:首查延迟——下单 40min 内不查 OMG(否则按 MerchantID 403 罚 30min)(T055)
+            if (omgQueryThrottle.isWithinFirstQueryDelay(p.getCreateTime())) {
+                log.debug("[OMG-{}] 首查延迟(<40min),跳过本行: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo());
+                continue;
+            }
+            // 闸2:per-MerchantID 令牌桶(1token/3s,burst1)——拿不到令牌跳过本行本轮,不阻塞(T055)
+            if (!omgQueryThrottle.tryAcquireMerchantToken(p.getMerchantId())) {
+                log.debug("[OMG-{}] MerchantID 令牌桶限流,跳过本行本轮: ddId={}, mtcn={}", source, ddId, p.getMerchantTradeNo());
+                continue;
+            }
             OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
             Map<String, String> resp;
             try {

+ 75 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/omg/OmgQueryThrottle.java

@@ -0,0 +1,75 @@
+package com.ruoyi.app.utils.omg;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * OMG queryTrade 三道节流闸(T055,防 OMG 按 MerchantID HTTP 403 自残)。
+ *
+ * <p>设计见 {@code specs/016-omg-payment/payment-attempt-lifecycle.md} §6.4【C】。三闸:
+ * <ol>
+ *   <li>闸1 首查延迟:下单后 N 分钟(默认 40)内不查 OMG(OMG/ECPay 节流,过早查触发 403 罚 30 分钟)。
+ *       跳“新”行(创建距今 &lt; 40min),与 OMG 官方“下单后 40 分钟内别查”一致。</li>
+ *   <li>闸2 per-MerchantID 令牌桶:同一商店代号每 interval(默认 3s)只放行一次 queryTrade(burst 1),
+ *       非阻塞——拿不到令牌跳过本行本轮。</li>
+ *   <li>闸3 per-ddId 扫描间隔:{@code /query} 默认 60s、定时默认 180s 内不重复查同一订单(挡前端 2s 轮询放大)。</li>
+ * </ol>
+ * 全部基于 Redis {@code SET NX EX}(原子),无锁竞争。参数 §9 待 OMG 官方确认后据实调整。
+ */
+@Component
+public class OmgQueryThrottle {
+
+    @Value("${omg.query-throttle.first-query-minutes:40}")
+    private int firstQueryMinutes;
+
+    @Value("${omg.query-throttle.merchant-token-seconds:3}")
+    private int merchantTokenSeconds;
+
+    @Value("${omg.query-throttle.query-dd-seconds:60}")
+    private int queryDdSeconds;
+
+    @Value("${omg.query-throttle.reconcile-dd-seconds:180}")
+    private int reconcileDdSeconds;
+
+    private final StringRedisTemplate redis;
+
+    public OmgQueryThrottle(StringRedisTemplate redis) {
+        this.redis = redis;
+    }
+
+    /** 闸1:该流水是否仍在首查延迟窗口内(创建距今 &lt; firstQueryMinutes 分钟 → 跳过不查)。未知创建时间不拦。 */
+    public boolean isWithinFirstQueryDelay(Date createTime) {
+        if (createTime == null) {
+            return false;
+        }
+        long ageMs = System.currentTimeMillis() - createTime.getTime();
+        return ageMs < firstQueryMinutes * 60_000L;
+    }
+
+    /** 闸2:per-MerchantID 令牌桶(1 token / merchantTokenSeconds,burst 1)。true=已占位可查;false=限流跳过本行本轮。 */
+    public boolean tryAcquireMerchantToken(String merchantId) {
+        if (merchantId == null || merchantId.isEmpty()) {
+            return true;
+        }
+        String key = "omg:qt:token:" + merchantId;
+        Boolean ok = redis.opsForValue().setIfAbsent(key, "1", merchantTokenSeconds, TimeUnit.SECONDS);
+        return Boolean.TRUE.equals(ok);
+    }
+
+    /** 闸3:per-ddId 扫描间隔。source="query"→/query(queryDdSeconds);其他→定时(reconcileDdSeconds)。
+     *  true=可处理;false=窗口内重复,跳过本轮。 */
+    public boolean acquireDdIdSlot(String ddId, String source) {
+        if (ddId == null || ddId.isEmpty()) {
+            return true;
+        }
+        boolean passive = "query".equals(source);
+        String key = (passive ? "omg:query:dd:" : "omg:reconcile:dd:") + ddId;
+        long ttl = passive ? queryDdSeconds : reconcileDdSeconds;
+        Boolean ok = redis.opsForValue().setIfAbsent(key, "1", ttl, TimeUnit.SECONDS);
+        return Boolean.TRUE.equals(ok);
+    }
+}