Parcourir la source

fix(omg-pay): T058 create 复用预检 + afterCompletion 锁(追加式 S 防堆积)+ existsPaidByDdId 补 SQL

T058 账本重构核心——问题 A(每次 create 堆积流水,连点 9 行)收敛:

- PaymentCreateGuardService(新增,共享支付创建串行锁):
  · withLock 锁释放改 afterCompletion(TX 提交/回滚后释放,仿 WalletService.returnPoints);
    isSynchronizationActive 守卫——@Transactional 调用方(OMG create)延迟到事务结束后释放,
    非事务调用方(LINE Pay)走 finally 即时释放。消除 kill-shot ⑨(锁在 TX commit 前释放→
    并发请求看不到未提交活跃行 INSERT→重复活跃行)。
  · lock.lock()→tryLock(3s)+看门狗(无 leaseTime 自动续期,事务未提交锁不致过期)。
- OmgPayController:
  · create 拆 createUnderLock 并经 PaymentCreateGuardService.withLock 串行化;
  · resolveMerchantTradeNo:getActiveForReuse 命中(新鲜期 3min + trade_no IS NULL)→复用 MTN
    不新建流水;未命中→markActiveHistorical(旧 is_active 1→0,pay_status 不动)+ 新建活跃行。
    连点/重发起不再堆积新流水。
- application.yml:omg.create.reuse-fresh-minutes=3(§9 stage 实测同 MTN 重 POST 行为后调整)。
- existsPaidByDdId 补 SQL(pay_status IN(1,3,4)),修 T057 空 stub(防空 SQL 崩,T059 读侧迁移前置)。
- PaymentCreateGuardServiceTest:锁非事务路径即时释放回归(挡 afterCommit 误回归)。

依赖 T056(DDL is_active/expire_date/uk_omg_active_dd)+ T057(实体/mapper selectActiveForReuse/
markActiveHistorical),已 commit c67b136,须一起部署(先执行 DDL→再上代码)。
4 代理对抗验证:T056/T057 基础 7 项全 PASS、复用单活跃不变量 PASS、锁 blocker(共享锁对非事务
调用方漏锁)已由 isSynchronizationActive 自愈解决。

Co-Authored-By: Claude <noreply@anthropic.com>
qmj il y a 2 semaines
Parent
commit
000f47f

+ 38 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java

@@ -100,6 +100,8 @@ public class OmgPayController extends BaseController {
     private IPosOrderOmgRefundService refundService;
     @Autowired
     private OrderLifecycleService orderLifecycleService;
+    @Autowired
+    private PaymentCreateGuardService paymentCreateGuardService;
 
     @Value("${omg.base-url}")
     private String baseUrl;
@@ -110,6 +112,10 @@ public class OmgPayController extends BaseController {
     @Value("${omg.payment-info-url}")
     private String paymentInfoUrl;
 
+    /** create 复用新鲜期(分钟):窗口内且 trade_no 为空的活跃行复用 MTN,超期则轮换新建(T058 防堆积,stage 实测调整)。 */
+    @Value("${omg.create.reuse-fresh-minutes:3}")
+    private int reuseFreshMinutes;
+
     // ============================ US1:发起 AIO 幕前支付 ============================
 
     /**
@@ -126,6 +132,15 @@ public class OmgPayController extends BaseController {
         if (invalidOrderRequest(request)) {
             return error(MessageUtils.message("no.order.id.error"));
         }
+        return paymentCreateGuardService == null ? createUnderLock(token, request)
+                : paymentCreateGuardService.withLock(request.getOrderid(),
+                () -> createUnderLock(token, request));
+    }
+
+    private AjaxResult createUnderLock(String token, OmgOrderRequest request) {
+        if (invalidOrderRequest(request)) {
+            return error(MessageUtils.message("no.order.id.error"));
+        }
         String orderid = request.getOrderid();
         String userId;
         try {
@@ -144,6 +159,9 @@ public class OmgPayController extends BaseController {
         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("订单已取消,不可重新支付");
         }
@@ -164,7 +182,7 @@ public class OmgPayController extends BaseController {
         }
         OmgPayConfig cfg = new OmgPayConfig(cred.getMerchantId(), cred.getHashKey(), cred.getHashIv());
 
-        String merchantTradeNo = createPaymentAttempt(order, cred);
+        String merchantTradeNo = resolveMerchantTradeNo(order, cred);
 
         // OMG AIO 参数(contracts/api.md §A1/B1);InvoiceMark=N 固定(发票走 ezPay);EncryptType=1 固定(SHA256)
         SimpleDateFormat fmt = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
@@ -204,6 +222,25 @@ public class OmgPayController extends BaseController {
         return success(form);
     }
 
+    /**
+     * 解析本次发起用的 MerchantTradeNo(T058 防堆积核心)。
+     *
+     * <p>优先复用新鲜期内活跃未付且未取号(trade_no IS NULL)的旧 MTN(连点/重发起命中 → 不新建流水);
+     * 未命中则先把旧行轮换为历史(is_active 1→0,pay_status 不动,保留接迟到 notify 与审计),再新建活跃行。
+     * 必须在 create 的 @Transactional 与 PaymentCreateGuardService 分布式锁内调用。
+     */
+    private String resolveMerchantTradeNo(PosOrder order, PosStoreOmg cred) {
+        String ddId = String.valueOf(order.getDdId());
+        PosOrderOmgPayment reusable = paymentService.getActiveForReuse(ddId, reuseFreshMinutes);
+        if (reusable != null) {
+            log.info("[OMG] create reuse orderid={}, merchantTradeNo={} (fresh within {}min, trade_no null)",
+                    order.getDdId(), reusable.getMerchantTradeNo(), reuseFreshMinutes);
+            return reusable.getMerchantTradeNo();
+        }
+        paymentService.markActiveHistorical(ddId);
+        return createPaymentAttempt(order, cred);
+    }
+
     /**
      * MerchantTradeNo 生成:OMG 要求全平台永久唯一、≤20 字元、英数大小写混合,且不可复用。
      * 使用 "OMG" + UUID 随机片段,并依靠数据库唯一索引与冲突重试兜底;ddId 由支付流水反查。

+ 75 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PaymentCreateGuardService.java

@@ -0,0 +1,75 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Serializes payment-channel creation for the same business order across providers.
+ *
+ * <p>锁释放在事务提交后(afterCommit)—— 调用方必须处于 {@code @Transactional} 中。若在 finally 直接
+ * unlock,锁在方法返回时释放,而事务在 AOP after-returning 才提交,被阻塞的并发请求看不到未提交的活跃
+ * 流水 INSERT,会再建一条活跃行(连点堆积)。仿 {@code WalletService.returnPoints} 的 afterCommit 模式。
+ */
+@Service
+public class PaymentCreateGuardService {
+    private static final String PREFIX = "lock:payment:create:";
+    /** 获取锁最长等待(秒):超时让调用方快速失败,避免用户长时间阻塞。 */
+    private static final long LOCK_WAIT_SECONDS = 3L;
+
+    private final RedissonClient redissonClient;
+
+    public PaymentCreateGuardService(RedissonClient redissonClient) {
+        this.redissonClient = redissonClient;
+    }
+
+    public <T> T withLock(String ddId, LockedCall<T> call) {
+        if (ddId == null || ddId.trim().isEmpty()) {
+            throw new IllegalArgumentException("ddId is required");
+        }
+        RLock lock = redissonClient.getLock(PREFIX + ddId.trim());
+        boolean acquired;
+        try {
+            // tryLock(waitTime, unit):等待 3s,获取后由 Redisson 看门狗自动续期(未指定 leaseTime),
+            // 避免事务未提交锁先过期;锁最终在 afterCommit 释放。
+            acquired = lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
+        }
+        if (!acquired) {
+            throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
+        }
+        boolean deferredUnlock = false;
+        try {
+            T result = call.call();
+            if (TransactionSynchronizationManager.isSynchronizationActive()) {
+                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                    @Override
+                    public void afterCompletion(int status) {
+                        if (lock.isHeldByCurrentThread()) {
+                            lock.unlock();
+                        }
+                    }
+                });
+                deferredUnlock = true;
+            }
+            return result;
+        } finally {
+            if (!deferredUnlock && lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
+
+    @FunctionalInterface
+    public interface LockedCall<T> {
+        T call();
+    }
+}

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

@@ -49,6 +49,10 @@ omg:
   payment-info-url: https://foodieapi.waimai-paotui.com/pay/omg/paymentInfo
   # ATM/超商取号前端展示页(可选)
   client-redirect-url: https://foodieapi.waimai-paotui.com/#/pages/payInfo
+  # create 复用预检(T058 防堆积):连点/重发起时复用新鲜期内活跃未付未取号的旧 MTN,避免每次新建流水
+  create:
+    # 复用新鲜期(分钟):窗口内且 trade_no 仍为空的活跃行复用 MTN,超期则轮换旧行新建;stage 实测同 MTN 重 POST 行为后调整
+    reuse-fresh-minutes: 3
   # 漏单补单定时兜底(方案B,OmgReconcileTask)—— 回调丢失不丢单
   reconcile:
     # 调度固定延迟(毫秒,上一轮跑完才开始计时),默认 3 分钟

+ 39 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/PaymentCreateGuardServiceTest.java

@@ -0,0 +1,39 @@
+package com.ruoyi.app.pay;
+
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+
+class PaymentCreateGuardServiceTest {
+
+    @Test
+    void usesSharedOrderLockWithWatchdogAndReleasesIt() {
+        RedissonClient redisson = mock(RedissonClient.class);
+        RLock lock = mock(RLock.class);
+        when(redisson.getLock("lock:payment:create:DD-1")).thenReturn(lock);
+        try {
+            when(lock.tryLock(anyLong(), eq(java.util.concurrent.TimeUnit.SECONDS))).thenReturn(true);
+        } catch (InterruptedException exception) {
+            throw new AssertionError(exception);
+        }
+        when(lock.isHeldByCurrentThread()).thenReturn(true);
+        PaymentCreateGuardService guard = new PaymentCreateGuardService(redisson);
+
+        String result = guard.withLock("DD-1", () -> "done");
+
+        assertEquals("done", result);
+        try {
+            verify(lock).tryLock(anyLong(), eq(java.util.concurrent.TimeUnit.SECONDS));
+        } catch (InterruptedException exception) {
+            throw new AssertionError(exception);
+        }
+        verify(lock).unlock();
+    }
+}

+ 3 - 1
ruoyi-system/src/main/resources/mapper/chanting/PosOrderOmgPaymentMapper.xml

@@ -89,8 +89,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         LIMIT 1
     </select>
 
-    <!-- 是否存在已付/退款中/已退行(校验用) -->
+    <!-- 是否存在已付/退款中/已退行(校验用):pay_status IN(1,3,4) -->
     <select id="existsPaidByDdId" parameterType="String" resultType="int">
+        SELECT COUNT(*) FROM pos_order_omg_payment
+        WHERE dd_id = #{ddId} AND pay_status IN (1,3,4)
     </select>
 
     <!-- 复用候选:活跃未付(is_active=1, pay_status=0)且未取号(trade_no IS NULL)且新鲜期内,create 复用 MTN(连点防堆积) -->