Просмотр исходного кода

feat(omgpay): add automatic payment compensation

qmj 2 недель назад
Родитель
Сommit
d2d0864764

+ 99 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentAutoCompensationTask.java

@@ -0,0 +1,99 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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;
+
+/** Periodically queries due CREATED attempts to recover payment callbacks that were lost. */
+@Component
+public class OmgPaymentAutoCompensationTask {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentAutoCompensationTask.class);
+    private static final String LOCK_KEY = "lock:omgpay:automatic-compensation";
+
+    @Value("${omgpay.reconcile.batch-size:20}")
+    int batchSize = 20;
+    @Value("${omgpay.reconcile.retry-delay-ms:300000}")
+    long retryDelayMs = 300000L;
+    @Value("${omgpay.reconcile.round-budget-seconds:45}")
+    long roundBudgetSeconds = 45L;
+
+    private final IOmgPaymentAttemptService attempts;
+    private final OmgPaymentQueryService queryService;
+    private final RedissonClient redissonClient;
+
+    public OmgPaymentAutoCompensationTask(IOmgPaymentAttemptService attempts,
+                                          OmgPaymentQueryService queryService,
+                                          RedissonClient redissonClient) {
+        this.attempts = attempts;
+        this.queryService = queryService;
+        this.redissonClient = redissonClient;
+    }
+
+    @Scheduled(fixedDelayString = "${omgpay.reconcile.fixed-delay-ms:60000}",
+            initialDelayString = "${omgpay.reconcile.initial-delay-ms:60000}")
+    public void reconcile() {
+        RLock lock = null;
+        try {
+            lock = redissonClient.getLock(LOCK_KEY);
+            if (!lock.tryLock()) {
+                return;
+            }
+            reconcileRound();
+        } catch (Exception exception) {
+            log.error("OMG automatic compensation round failed", exception);
+        } finally {
+            if (lock != null && lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
+
+    void reconcileRound() {
+        long started = System.currentTimeMillis();
+        Date now = new Date(started);
+        List<OmgPaymentAttempt> dueAttempts = attempts.scanDueCreated(now, batchSize);
+        int reserved = 0;
+        int completed = 0;
+        for (OmgPaymentAttempt attempt : dueAttempts) {
+            if (roundBudgetExceeded(started)) {
+                break;
+            }
+            Date nextQueryTime = new Date(System.currentTimeMillis() + retryDelayMs);
+            if (attempts.reserveDueQuery(attempt.getId(), new Date(), nextQueryTime) != 1) {
+                continue;
+            }
+            reserved++;
+            try {
+                OmgQueryPaymentResponse response = queryService.reconcile(attempt);
+                completed++;
+                log.info("OMG automatic compensation processed attemptId={}, orderId={}, merchantTradeNo={}, "
+                                + "gatewayTradeStatus={}, localStatus={}",
+                        attempt.getId(), OmgPaymentController.safeLogOrderId(attempt.getDdId()),
+                        OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
+                        response.tradeStatus(), response.status());
+            } catch (Exception exception) {
+                log.error("OMG automatic compensation query failed attemptId={}, orderId={}, merchantTradeNo={}",
+                        attempt.getId(), OmgPaymentController.safeLogOrderId(attempt.getDdId()),
+                        OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), exception);
+            }
+        }
+        if (!dueAttempts.isEmpty()) {
+            log.info("OMG automatic compensation round completed scanned={}, reserved={}, completed={}",
+                    dueAttempts.size(), reserved, completed);
+        }
+    }
+
+    private boolean roundBudgetExceeded(long started) {
+        return System.currentTimeMillis() - started >= roundBudgetSeconds * 1000L;
+    }
+}

+ 20 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java

@@ -82,25 +82,40 @@ public class OmgPaymentQueryService {
         log.info("OMG payment query started orderId={}, userId={}, storeId={}, merchantTradeNo={}",
         log.info("OMG payment query started orderId={}, userId={}, storeId={}, merchantTradeNo={}",
                 OmgPaymentController.safeLogOrderId(order.getDdId()), userId, order.getStoreId(),
                 OmgPaymentController.safeLogOrderId(order.getDdId()), userId, order.getStoreId(),
                 OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()));
                 OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()));
+        return queryAttempt(attempt, order.getDdId(), order.getStoreId(), "USER");
+    }
+
+    /** Reuses the verified query and settlement path for one scheduler-reserved CREATED attempt. */
+    public OmgQueryPaymentResponse reconcile(OmgPaymentAttempt attempt) {
+        Long storeId = attempt == null ? null : attempt.getStoreId();
+        validateAttempt(attempt, storeId);
+        log.info("OMG automatic compensation query started orderId={}, storeId={}, merchantTradeNo={}, queryCount={}",
+                OmgPaymentController.safeLogOrderId(attempt.getDdId()), storeId,
+                OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
+                attempt.getQueryCount());
+        return queryAttempt(attempt, attempt.getDdId(), storeId, "AUTO");
+    }
 
 
+    private OmgQueryPaymentResponse queryAttempt(OmgPaymentAttempt attempt, String orderId,
+                                                  Long storeId, String source) {
         try {
         try {
             Map<String, String> response = parser.parse(gateway.query(buildRequest(attempt)));
             Map<String, String> response = parser.parse(gateway.query(buildRequest(attempt)));
             validateResponse(response, attempt);
             validateResponse(response, attempt);
             String tradeStatus = response.get("TradeStatus");
             String tradeStatus = response.get("TradeStatus");
             String status = synchronizeIfFinal(tradeStatus, response, attempt);
             String status = synchronizeIfFinal(tradeStatus, response, attempt);
-            log.info("OMG payment query completed orderId={}, merchantTradeNo={}, tradeNo={}, "
+            log.info("OMG payment query completed source={}, orderId={}, merchantTradeNo={}, tradeNo={}, "
                             + "gatewayTradeStatus={}, localStatus={}",
                             + "gatewayTradeStatus={}, localStatus={}",
-                    OmgPaymentController.safeLogOrderId(order.getDdId()),
+                    source, OmgPaymentController.safeLogOrderId(orderId),
                     OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
                     OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
                     maskTradeNo(response.get("TradeNo")), tradeStatus, status);
                     maskTradeNo(response.get("TradeNo")), tradeStatus, status);
             return toResponse(status, response);
             return toResponse(status, response);
         } catch (OmgPaymentBusinessException exception) {
         } catch (OmgPaymentBusinessException exception) {
             throw exception;
             throw exception;
         } catch (Exception exception) {
         } catch (Exception exception) {
-            log.error("OMG payment query failed orderId={}, merchantTradeNo={}",
-                    OmgPaymentController.safeLogOrderId(order.getDdId()),
+            log.error("OMG payment query failed source={}, orderId={}, merchantTradeNo={}",
+                    source, OmgPaymentController.safeLogOrderId(orderId),
                     OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), exception);
                     OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), exception);
-            throw business(PAYMENT_QUERY_FAILED, order.getStoreId());
+            throw business(PAYMENT_QUERY_FAILED, storeId);
         }
         }
     }
     }
 
 

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

@@ -44,6 +44,13 @@ omg:
 
 
 omgpay:
 omgpay:
   return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify
   return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify
+  reconcile:
+    # 自动补偿只扫描到期的 CREATED 尝试;多实例由分布式锁保证单轮仅一个节点执行。
+    fixed-delay-ms: 60000
+    initial-delay-ms: 60000
+    batch-size: 20
+    retry-delay-ms: 300000
+    round-budget-seconds: 45
 
 
 # IM 即时沟通配置
 # IM 即时沟通配置
 im:
 im:

+ 93 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentAutoCompensationTaskTest.java

@@ -0,0 +1,93 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+
+import java.util.Date;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+class OmgPaymentAutoCompensationTaskTest {
+    private IOmgPaymentAttemptService attempts;
+    private OmgPaymentQueryService queryService;
+    private RedissonClient redisson;
+    private RLock lock;
+    private OmgPaymentAutoCompensationTask task;
+
+    @BeforeEach
+    void setUp() {
+        attempts = mock(IOmgPaymentAttemptService.class);
+        queryService = mock(OmgPaymentQueryService.class);
+        redisson = mock(RedissonClient.class);
+        lock = mock(RLock.class);
+        task = new OmgPaymentAutoCompensationTask(attempts, queryService, redisson);
+        task.batchSize = 20;
+        task.retryDelayMs = 300000L;
+        task.roundBudgetSeconds = 45L;
+    }
+
+    @Test
+    void skipsRoundWhenAnotherNodeOwnsTheLock() {
+        when(redisson.getLock("lock:omgpay:automatic-compensation")).thenReturn(lock);
+        when(lock.tryLock()).thenReturn(false);
+
+        task.reconcile();
+
+        verifyNoInteractions(attempts, queryService);
+        verify(lock, never()).unlock();
+    }
+
+    @Test
+    void reservesDueAttemptsAndContinuesAfterOneGatewayFailure() {
+        OmgPaymentAttempt first = attempt(7L, "DD-1", "OMG123");
+        OmgPaymentAttempt second = attempt(8L, "DD-2", "OMG456");
+        when(redisson.getLock("lock:omgpay:automatic-compensation")).thenReturn(lock);
+        when(lock.tryLock()).thenReturn(true);
+        when(lock.isHeldByCurrentThread()).thenReturn(true);
+        when(attempts.scanDueCreated(any(Date.class), eq(20))).thenReturn(List.of(first, second));
+        when(attempts.reserveDueQuery(eq(7L), any(Date.class), any(Date.class))).thenReturn(1);
+        when(attempts.reserveDueQuery(eq(8L), any(Date.class), any(Date.class))).thenReturn(1);
+        when(queryService.reconcile(first)).thenThrow(new RuntimeException("temporary"));
+        when(queryService.reconcile(second)).thenReturn(response("UNPAID", "0"));
+
+        task.reconcile();
+
+        verify(queryService).reconcile(first);
+        verify(queryService).reconcile(second);
+        verify(lock).unlock();
+    }
+
+    @Test
+    void doesNotQueryAttemptThatLostTheReservationRace() {
+        OmgPaymentAttempt attempt = attempt(7L, "DD-1", "OMG123");
+        when(attempts.scanDueCreated(any(Date.class), anyInt())).thenReturn(List.of(attempt));
+        when(attempts.reserveDueQuery(eq(7L), any(Date.class), any(Date.class))).thenReturn(0);
+
+        task.reconcileRound();
+
+        verifyNoInteractions(queryService);
+    }
+
+    private static OmgPaymentAttempt attempt(Long id, String ddId, String merchantTradeNo) {
+        OmgPaymentAttempt result = new OmgPaymentAttempt();
+        result.setId(id);
+        result.setDdId(ddId);
+        result.setMerchantTradeNo(merchantTradeNo);
+        result.setStoreId(77L);
+        return result;
+    }
+
+    private static OmgQueryPaymentResponse response(String status, String tradeStatus) {
+        return new OmgQueryPaymentResponse(status, tradeStatus, "OMG456", null,
+                100, null, null, null, "0", "0.00");
+    }
+}

+ 13 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryServiceTest.java

@@ -65,6 +65,19 @@ class OmgPaymentQueryServiceTest {
         assertEquals(4, request.getValue().size());
         assertEquals(4, request.getValue().size());
     }
     }
 
 
+    @Test
+    void schedulerReconciliationUsesTheSameVerifiedSettlementPath() {
+        when(gateway.query(anyMap())).thenReturn(signedResponse("1", Map.of()));
+        when(settlement.synchronizeVerifiedQuery(any())).thenReturn(OmgPaymentSettlementResult.PAID);
+
+        assertEquals("PAID", service.reconcile(attempt).status());
+
+        verify(settlement).synchronizeVerifiedQuery(argThat(facts ->
+                facts.source() == OmgPaymentFactSource.QUERY
+                        && facts.merchantTradeNo().equals("OMG123")));
+        verify(attempts, never()).selectOrder(anyString());
+    }
+
     @Test
     @Test
     void unpaidResultIsReadOnlyAndExplicitFailureIsSynchronized() {
     void unpaidResultIsReadOnlyAndExplicitFailureIsSynchronized() {
         when(gateway.query(anyMap())).thenReturn(signedResponse("0", Map.of()));
         when(gateway.query(anyMap())).thenReturn(signedResponse("0", Map.of()));

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentAttempt.java

@@ -37,6 +37,9 @@ public class OmgPaymentAttempt {
     private BigDecimal paymentTypeChargeFee;
     private BigDecimal paymentTypeChargeFee;
     private Integer simulatePaid;
     private Integer simulatePaid;
     private Date lastNotifyTime;
     private Date lastNotifyTime;
+    private Date nextQueryTime;
+    private Integer queryCount;
+    private Date lastQueryTime;
     @TableField(fill = FieldFill.INSERT)
     @TableField(fill = FieldFill.INSERT)
     private Date createTime;
     private Date createTime;
     @TableField(fill = FieldFill.INSERT_UPDATE)
     @TableField(fill = FieldFill.INSERT_UPDATE)

+ 11 - 1
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java

@@ -4,6 +4,9 @@ import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Param;
 
 
+import java.util.Date;
+import java.util.List;
+
 /**
 /**
  * Persistence boundary for locking order facts and appending OMG payment attempts.
  * Persistence boundary for locking order facts and appending OMG payment attempts.
  */
  */
@@ -28,6 +31,13 @@ public interface OmgPaymentAttemptMapper {
     /** Preliminary read used to discover the order before locks are acquired in order-first sequence. */
     /** Preliminary read used to discover the order before locks are acquired in order-first sequence. */
     OmgPaymentAttempt selectByMerchantTradeNo(@Param("merchantTradeNo") String merchantTradeNo);
     OmgPaymentAttempt selectByMerchantTradeNo(@Param("merchantTradeNo") String merchantTradeNo);
 
 
+    /** CREATED attempts whose next automatic query is due, ordered fairly by due time. */
+    List<OmgPaymentAttempt> selectDueCreated(@Param("now") Date now, @Param("limit") int limit);
+
+    /** Atomically reserves one due attempt and advances its next query time. */
+    int reserveDueQuery(@Param("id") Long id, @Param("now") Date now,
+                        @Param("nextQueryTime") Date nextQueryTime);
+
     int insertCreated(OmgPaymentAttempt row);
     int insertCreated(OmgPaymentAttempt row);
 
 
     int markPaid(OmgPaymentAttempt row);
     int markPaid(OmgPaymentAttempt row);
@@ -35,7 +45,7 @@ public interface OmgPaymentAttemptMapper {
     int markFailed(OmgPaymentAttempt row);
     int markFailed(OmgPaymentAttempt row);
 
 
     int supersedeOtherCreated(@Param("ddId") String ddId, @Param("paidAttemptId") Long paidAttemptId,
     int supersedeOtherCreated(@Param("ddId") String ddId, @Param("paidAttemptId") Long paidAttemptId,
-                              @Param("updateTime") java.util.Date updateTime);
+                              @Param("updateTime") Date updateTime);
 
 
     int markOrderPaid(@Param("ddId") String ddId);
     int markOrderPaid(@Param("ddId") String ddId);
 
 

+ 7 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java

@@ -3,6 +3,9 @@ package com.ruoyi.system.omgpay.service;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 
 
+import java.util.Date;
+import java.util.List;
+
 /**
 /**
  * Transaction-participating operations needed to create one OMG payment attempt.
  * Transaction-participating operations needed to create one OMG payment attempt.
  */
  */
@@ -23,6 +26,10 @@ public interface IOmgPaymentAttemptService {
 
 
     OmgPaymentAttempt selectByMerchantTradeNo(String merchantTradeNo);
     OmgPaymentAttempt selectByMerchantTradeNo(String merchantTradeNo);
 
 
+    List<OmgPaymentAttempt> scanDueCreated(Date now, int limit);
+
+    int reserveDueQuery(Long id, Date now, Date nextQueryTime);
+
     OmgPaymentAttempt createCreated(String ddId, String merchantTradeNo, Long storeId,
     OmgPaymentAttempt createCreated(String ddId, String merchantTradeNo, Long storeId,
                                     String merchantId, Integer amount,
                                     String merchantId, Integer amount,
                                     String hashKeySnapshot, String hashIvSnapshot);
                                     String hashKeySnapshot, String hashIvSnapshot);

+ 19 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java

@@ -10,6 +10,7 @@ import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import java.util.Date;
 import java.util.Date;
+import java.util.List;
 
 
 /**
 /**
  * Validates and persists OMG attempt facts inside the caller's creation transaction.
  * Validates and persists OMG attempt facts inside the caller's creation transaction.
@@ -62,6 +63,22 @@ public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
                 ? null : mapper.selectByMerchantTradeNo(merchantTradeNo.trim());
                 ? null : mapper.selectByMerchantTradeNo(merchantTradeNo.trim());
     }
     }
 
 
+    @Override
+    public List<OmgPaymentAttempt> scanDueCreated(Date now, int limit) {
+        if (now == null || limit <= 0) {
+            return List.of();
+        }
+        return mapper.selectDueCreated(now, Math.min(limit, 100));
+    }
+
+    @Override
+    public int reserveDueQuery(Long id, Date now, Date nextQueryTime) {
+        if (id == null || now == null || nextQueryTime == null || !nextQueryTime.after(now)) {
+            return 0;
+        }
+        return mapper.reserveDueQuery(id, now, nextQueryTime);
+    }
+
     @Override
     @Override
     public OmgPaymentAttempt createCreated(String ddId, String merchantTradeNo, Long storeId,
     public OmgPaymentAttempt createCreated(String ddId, String merchantTradeNo, Long storeId,
                                            String merchantId, Integer amount,
                                            String merchantId, Integer amount,
@@ -78,6 +95,8 @@ public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
         row.setHashKeySnapshot(hashKeySnapshot);
         row.setHashKeySnapshot(hashKeySnapshot);
         row.setHashIvSnapshot(hashIvSnapshot);
         row.setHashIvSnapshot(hashIvSnapshot);
         row.setAttemptStatus(ATTEMPT_STATUS_CREATED);
         row.setAttemptStatus(ATTEMPT_STATUS_CREATED);
+        row.setNextQueryTime(now);
+        row.setQueryCount(0);
         row.setCreateTime(now);
         row.setCreateTime(now);
         row.setUpdateTime(now);
         row.setUpdateTime(now);
         mapper.insertCreated(row);
         mapper.insertCreated(row);

+ 22 - 2
ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml

@@ -11,6 +11,8 @@
         payment_type AS paymentType, payment_date AS paymentDate, trade_date AS tradeDate,
         payment_type AS paymentType, payment_date AS paymentDate, trade_date AS tradeDate,
         payment_type_charge_fee AS paymentTypeChargeFee, simulate_paid AS simulatePaid,
         payment_type_charge_fee AS paymentTypeChargeFee, simulate_paid AS simulatePaid,
         last_notify_time AS lastNotifyTime,
         last_notify_time AS lastNotifyTime,
+        next_query_time AS nextQueryTime, query_count AS queryCount,
+        last_query_time AS lastQueryTime,
         create_time AS createTime, update_time AS updateTime
         create_time AS createTime, update_time AS updateTime
     </sql>
     </sql>
 
 
@@ -71,14 +73,32 @@
         LIMIT 1
         LIMIT 1
     </select>
     </select>
 
 
+    <select id="selectDueCreated"
+            resultType="com.ruoyi.system.omgpay.domain.OmgPaymentAttempt">
+        SELECT <include refid="attemptColumns"/>
+        FROM pos_order_omg_attempt
+        WHERE attempt_status = 0 AND next_query_time &lt;= #{now}
+        ORDER BY next_query_time ASC, id ASC
+        LIMIT #{limit}
+    </select>
+
+    <update id="reserveDueQuery">
+        UPDATE pos_order_omg_attempt
+        SET last_query_time = #{now}, next_query_time = #{nextQueryTime},
+            query_count = query_count + 1, update_time = #{now}
+        WHERE id = #{id} AND attempt_status = 0 AND next_query_time &lt;= #{now}
+    </update>
+
     <!-- active_dd_id is a DB-generated key invariant, so Java inserts only the created facts. -->
     <!-- active_dd_id is a DB-generated key invariant, so Java inserts only the created facts. -->
     <insert id="insertCreated" useGeneratedKeys="true" keyProperty="id">
     <insert id="insertCreated" useGeneratedKeys="true" keyProperty="id">
         INSERT INTO pos_order_omg_attempt
         INSERT INTO pos_order_omg_attempt
           (dd_id, merchant_trade_no, store_id, merchant_id, amount,
           (dd_id, merchant_trade_no, store_id, merchant_id, amount,
-           hash_key_snapshot, hash_iv_snapshot, attempt_status, create_time, update_time)
+           hash_key_snapshot, hash_iv_snapshot, attempt_status,
+           next_query_time, query_count, create_time, update_time)
         VALUES
         VALUES
           (#{ddId}, #{merchantTradeNo}, #{storeId}, #{merchantId}, #{amount},
           (#{ddId}, #{merchantTradeNo}, #{storeId}, #{merchantId}, #{amount},
-           #{hashKeySnapshot}, #{hashIvSnapshot}, #{attemptStatus}, #{createTime}, #{updateTime})
+           #{hashKeySnapshot}, #{hashIvSnapshot}, #{attemptStatus},
+           #{nextQueryTime}, #{queryCount}, #{createTime}, #{updateTime})
     </insert>
     </insert>
 
 
     <!-- PAID is irreversible; a late success may upgrade FAILED or SUPERSEDED. -->
     <!-- PAID is irreversible; a late success may upgrade FAILED or SUPERSEDED. -->

+ 4 - 0
ruoyi-system/src/test/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapperContractTest.java

@@ -40,6 +40,10 @@ class OmgPaymentAttemptMapperContractTest {
         assertTrue(xml.contains("attempt_status IN (0, 2, 3)"));
         assertTrue(xml.contains("attempt_status IN (0, 2, 3)"));
         assertTrue(xml.contains("attempt_status IN (0, 2)"));
         assertTrue(xml.contains("attempt_status IN (0, 2)"));
         assertTrue(xml.contains("UPDATE pos_order SET pay_status = 1"));
         assertTrue(xml.contains("UPDATE pos_order SET pay_status = 1"));
+        assertTrue(xml.contains("WHERE attempt_status = 0 AND next_query_time &lt;= #{now}"));
+        assertTrue(xml.contains("ORDER BY next_query_time ASC, id ASC"));
+        assertTrue(xml.contains("query_count = query_count + 1"));
+        assertTrue(sql.contains("idx_omg_attempt_query_due"));
         assertFalse(xml.contains("SET state ="));
         assertFalse(xml.contains("SET state ="));
         assertFalse(xml.contains("delivery_status"));
         assertFalse(xml.contains("delivery_status"));
     }
     }

+ 18 - 0
ruoyi-system/src/test/java/com/ruoyi/system/omgpay/service/OmgPaymentAttemptServiceTest.java

@@ -11,6 +11,9 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 
+import java.util.Date;
+import java.util.List;
+
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -93,9 +96,24 @@ class OmgPaymentAttemptServiceTest {
         assertEquals(7L, row.getId());
         assertEquals(7L, row.getId());
         assertEquals("KEY", row.getHashKeySnapshot());
         assertEquals("KEY", row.getHashKeySnapshot());
         assertEquals("IV", row.getHashIvSnapshot());
         assertEquals("IV", row.getHashIvSnapshot());
+        assertEquals(0, row.getQueryCount());
+        assertEquals(row.getCreateTime(), row.getNextQueryTime());
         assertNull(row.getActiveDdId());
         assertNull(row.getActiveDdId());
     }
     }
 
 
+    @Test
+    void dueScanIsBoundedAndReservationRejectsInvalidSchedule() {
+        Date now = new Date(1000L);
+        when(mapper.selectDueCreated(now, 100)).thenReturn(List.of());
+
+        service.scanDueCreated(now, 500);
+        assertEquals(0, service.reserveDueQuery(7L, now, now));
+
+        org.mockito.Mockito.verify(mapper).selectDueCreated(now, 100);
+        org.mockito.Mockito.verify(mapper, org.mockito.Mockito.never())
+                .reserveDueQuery(any(), any(), any());
+    }
+
     @Test
     @Test
     void createCreatedRejectsMissingCredentialSnapshot() {
     void createCreatedRejectsMissingCredentialSnapshot() {
         assertLocalizedValidationFailure("omg.payment.credential.snapshot.required",
         assertLocalizedValidationFailure("omg.payment.credential.snapshot.required",

+ 8 - 0
specs/016-omg-payment/plan.md

@@ -100,6 +100,14 @@ application.yml (+application-dev.yml) # 新增 omg.* 配置段
 
 
 **Structure Decision**:沿用项目既有分层(utils 在 `ruoyi-admin/.../app/utils/omg/`,domain/mapper/service 在 `ruoyi-system`,Controller 在 `ruoyi-admin/.../app/{pay,mendian}`),与 ezPay/newebpay 同构但**独立包/独立类/独立表**。退款入口(US4)并入 `OmgPayController` 或订单取消链路(`PosOrderShOprate`/`UserOrderController` 取消时触发),tasks 阶段定具体落点。
 **Structure Decision**:沿用项目既有分层(utils 在 `ruoyi-admin/.../app/utils/omg/`,domain/mapper/service 在 `ruoyi-system`,Controller 在 `ruoyi-admin/.../app/{pay,mendian}`),与 ezPay/newebpay 同构但**独立包/独立类/独立表**。退款入口(US4)并入 `OmgPayController` 或订单取消链路(`PosOrderShOprate`/`UserOrderController` 取消时触发),tasks 阶段定具体落点。
 
 
+## 2026-08-13 自动补偿增量计划
+
+- 在新支付尝试表增加 `next_query_time/query_count/last_query_time` 和到期扫描索引,SQL 仅登记不执行。
+- 每分钟由 `omgpay` 新目录内的任务竞争全局 Redisson 锁,按到期时间最多扫描 20 条 `CREATED` 尝试。
+- 每条记录先以 `attempt_status=CREATED AND next_query_time<=now` 条件更新进行预留,并把下一次查询推迟 5 分钟;预留失败不访问 OMG。
+- 复用已经完成的查询响应验签和支付状态机:已付款补成已付款,明确失败同步失败,未付款或暂时异常保留并等待下一轮。
+- 单轮限制 45 秒预算,单条异常记录完整异常上下文并继续,不记录 HashKey/HashIV。
+
 ## Complexity Tracking
 ## Complexity Tracking
 
 
 > Constitution Check 无违规,无需填表。
 > Constitution Check 无违规,无需填表。

+ 3 - 0
specs/016-omg-payment/spec.md

@@ -156,6 +156,8 @@ OMG 服务端回调(`ReturnURL`)到达后,验签 + 幂等更新订单,
 - **FR-012**:OMG **替代蓝新 NewebPay(011)** 作为订单在线支付启用,NewebPay 不再启用(D2)。
 - **FR-012**:OMG **替代蓝新 NewebPay(011)** 作为订单在线支付启用,NewebPay 不再启用(D2)。
 - **FR-013**:系统 MUST NOT 复用或依赖任何蓝新 NewebPay 代码/表;OMG 凭证、流水、退款表与工具类/Controller 全部独立新建,仅可使用平台共享基础设施(订单状态机、推送、订单日志)。
 - **FR-013**:系统 MUST NOT 复用或依赖任何蓝新 NewebPay 代码/表;OMG 凭证、流水、退款表与工具类/Controller 全部独立新建,仅可使用平台共享基础设施(订单状态机、推送、订单日志)。
 
 
+- **FR-017**:系统 MUST 自动补偿仍为 `CREATED` 的当前有效支付尝试。调度器按 `next_query_time` 取有限批次,并在真实查询前用条件更新预留该行;查询确认已付款或明确失败时必须复用主动查询的验签与同一状态机,未付款或暂时异常不得关闭支付尝试。
+
 ### Key Entities *(include if feature involves data)*
 ### Key Entities *(include if feature involves data)*
 
 
 - **门店 OMG 凭证(StoreOmgCredential)**:门店关联、MerchantID、HashKey、HashIV、环境(测试/生产)、启用开关。
 - **门店 OMG 凭证(StoreOmgCredential)**:门店关联、MerchantID、HashKey、HashIV、环境(测试/生产)、启用开关。
@@ -182,3 +184,4 @@ OMG 服务端回调(`ReturnURL`)到达后,验签 + 幂等更新订单,
 - 测试先用 `payment-stage.funpoint.com.tw` 测试端点与测试凭证,生产环境与凭证后续切换。
 - 测试先用 `payment-stage.funpoint.com.tw` 测试端点与测试凭证,生产环境与凭证后续切换。
 - 凭证签名等敏感操作在服务端完成,HashKey/HashIV 不下发前端。
 - 凭证签名等敏感操作在服务端完成,HashKey/HashIV 不下发前端。
 - 在线支付与现有「货到付款」并存,由门店配置决定是否提供在线支付。
 - 在线支付与现有「货到付款」并存,由门店配置决定是否提供在线支付。
+

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

@@ -197,6 +197,18 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 
 ---
 ---
 
 
+## Phase 13: 新实现自动补偿(Priority: P1)
+
+- [x] T076 在 `pos_order_omg_attempt` 增加自动查询调度字段、到期索引、Mapper 条件扫描及原子预留。
+- [x] T077 在新 `omgpay` 目录增加定时任务、全局分布式锁、有限批次、重试间隔和单轮时间预算。
+- [x] T078 自动任务复用可信查询验签与付款状态机,不复制回调或状态更新逻辑。
+- [x] T079 增加锁竞争、预留竞争、单条异常继续、自动查询支付成功补单及 Mapper/SQL 契约测试源码。
+- [ ] T080 全部 OMG 功能调整结束后统一运行 Maven 编译、定向测试和 stage 联调;本批按用户要求暂不执行。
+
+**Checkpoint**:自动补偿代码已实现;SQL 尚未手动执行,编译、测试及 stage 联调统一留到全部 OMG 功能完成后执行。
+
+---
+
 ## Dependencies & Execution Order
 ## Dependencies & Execution Order
 
 
 ### Phase 依赖
 ### Phase 依赖

+ 5 - 1
updatesql/sql.md

@@ -702,6 +702,9 @@ CREATE TABLE pos_order_omg_attempt (
   payment_type_charge_fee INT DEFAULT NULL COMMENT 'OMG 回传手续费',
   payment_type_charge_fee INT DEFAULT NULL COMMENT 'OMG 回传手续费',
   simulate_paid TINYINT DEFAULT NULL COMMENT '0一般付款,1模拟付款',
   simulate_paid TINYINT DEFAULT NULL COMMENT '0一般付款,1模拟付款',
   last_notify_time DATETIME DEFAULT NULL COMMENT '最近合法付款结果通知时间',
   last_notify_time DATETIME DEFAULT NULL COMMENT '最近合法付款结果通知时间',
+  next_query_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下一次允许自动查询的时间',
+  query_count INT NOT NULL DEFAULT 0 COMMENT '已预留的自动查询次数',
+  last_query_time DATETIME DEFAULT NULL COMMENT '最近一次自动查询预留时间',
   create_time DATETIME NOT NULL,
   create_time DATETIME NOT NULL,
   update_time DATETIME NOT NULL,
   update_time DATETIME NOT NULL,
   PRIMARY KEY (id),
   PRIMARY KEY (id),
@@ -709,7 +712,8 @@ CREATE TABLE pos_order_omg_attempt (
   UNIQUE KEY uk_omg_attempt_gateway_trade_no (trade_no),
   UNIQUE KEY uk_omg_attempt_gateway_trade_no (trade_no),
   UNIQUE KEY uk_omg_attempt_active_dd (active_dd_id),
   UNIQUE KEY uk_omg_attempt_active_dd (active_dd_id),
   KEY idx_omg_attempt_dd_time (dd_id, create_time, id),
   KEY idx_omg_attempt_dd_time (dd_id, create_time, id),
-  KEY idx_omg_attempt_store_time (store_id, create_time, id)
+  KEY idx_omg_attempt_store_time (store_id, create_time, id),
+  KEY idx_omg_attempt_query_due (attempt_status, next_query_time, id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OMG 支付尝试追加式账本';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OMG 支付尝试追加式账本';
 ```
 ```