Parcourir la source

修复:支持 OMG 未付款订单重新发起支付

qmj il y a 2 semaines
Parent
commit
11478ffbbc
29 fichiers modifiés avec 527 ajouts et 9 suppressions
  1. 33 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java
  2. 29 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java
  3. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentErrorCode.java
  4. 45 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRetryService.java
  5. 13 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgRetryPaymentRequest.java
  6. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  7. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  8. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  9. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  10. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  11. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgLegacyRetirementTest.java
  12. 1 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java
  13. 48 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java
  14. 2 1
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyControllerTest.java
  15. 1 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryControllerTest.java
  16. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRefundControllerTest.java
  17. 86 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRetryControllerTest.java
  18. 102 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRetryServiceTest.java
  19. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java
  20. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java
  21. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java
  22. 6 0
      ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml
  23. 2 0
      ruoyi-system/src/test/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapperContractTest.java
  24. 9 0
      ruoyi-system/src/test/java/com/ruoyi/system/omgpay/service/OmgPaymentAttemptServiceTest.java
  25. 19 0
      specs/020-omg-payment-rebuild/contracts/api.md
  26. 78 3
      specs/020-omg-payment-rebuild/omg-app-integration.md
  27. 12 2
      specs/020-omg-payment-rebuild/plan.md
  28. 8 1
      specs/020-omg-payment-rebuild/spec.md
  29. 9 0
      specs/020-omg-payment-rebuild/tasks.md

+ 33 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java

@@ -5,6 +5,7 @@ import com.ruoyi.app.omgpay.dto.OmgPaymentErrorResponse;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
 import com.ruoyi.app.omgpay.dto.OmgQueryPaymentRequest;
 import com.ruoyi.app.omgpay.dto.OmgRefundPaymentRequest;
+import com.ruoyi.app.omgpay.dto.OmgRetryPaymentRequest;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.MessageUtils;
@@ -32,6 +33,7 @@ public class OmgPaymentController {
     private final OmgPaymentNotifyService notifyService;
     private final OmgIpnAuditService ipnAuditService;
     private final OmgPaymentQueryService queryService;
+    private final OmgPaymentRetryService retryService;
     private final OmgPaymentRefundService refundService;
 
     public OmgPaymentController(OmgPaymentTokenUserResolver tokenUserResolver,
@@ -39,12 +41,14 @@ public class OmgPaymentController {
                                 OmgPaymentNotifyService notifyService,
                                 OmgIpnAuditService ipnAuditService,
                                 OmgPaymentQueryService queryService,
+                                OmgPaymentRetryService retryService,
                                 OmgPaymentRefundService refundService) {
         this.tokenUserResolver = tokenUserResolver;
         this.createService = createService;
         this.notifyService = notifyService;
         this.ipnAuditService = ipnAuditService;
         this.queryService = queryService;
+        this.retryService = retryService;
         this.refundService = refundService;
     }
 
@@ -133,6 +137,35 @@ public class OmgPaymentController {
         }
     }
 
+    @Anonymous
+    @Auth
+    @PostMapping("/retry")
+    public AjaxResult retry(@RequestHeader(name = "token") String token,
+                            @RequestBody(required = false) OmgRetryPaymentRequest request) {
+        String orderId = request == null ? null : request.getOrderId();
+        String safeOrderId = safeLogOrderId(orderId);
+        Long safeUserId = null;
+        try {
+            safeUserId = tokenUserResolver.requireUserId(token);
+            log.info("OMG payment retry started orderId={}, userId={}", safeOrderId, safeUserId);
+            OmgPaymentCreateOutcome outcome = retryService.retry(safeUserId, orderId);
+            log.info("OMG payment retry succeeded orderId={}, userId={}, storeId={}, "
+                            + "attemptId={}, amount={}, status=CREATED, merchantTradeNo={}",
+                    safeLogOrderId(outcome.orderId()), outcome.userId(), outcome.storeId(), outcome.attemptId(),
+                    outcome.amount(), outcome.maskedMerchantTradeNo());
+            return AjaxResult.success(outcome.response());
+        } catch (OmgPaymentBusinessException error) {
+            log.warn("OMG payment retry rejected orderId={}, userId={}, storeId={}, code={}",
+                    safeOrderId, safeUserId, error.getStoreId(), error.getCode());
+            return AjaxResult.error(MessageUtils.message(error.getMessageKey()),
+                    new OmgPaymentErrorResponse(error.getCode().name()));
+        } catch (Exception error) {
+            log.error("OMG payment retry failed orderId={}, userId={}", safeOrderId, safeUserId, error);
+            return AjaxResult.error(MessageUtils.message("omg.pay.retry.failed"),
+                    new OmgPaymentErrorResponse("PAYMENT_RETRY_FAILED"));
+        }
+    }
+
     @Anonymous
     @Auth
     @PostMapping("/refund")

+ 29 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java

@@ -52,6 +52,35 @@ public class OmgPaymentCreateService {
         return createWithBoundedTradeNumberRetries(order, userId, credential);
     }
 
+    /**
+     * Replaces the exact attempt previously verified as unpaid by the gateway.
+     * The order lock and MerchantTradeNo comparison prevent concurrent retries from both creating a new attempt.
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public OmgPaymentCreateOutcome replaceActiveForRetry(Long userId, String orderId,
+                                                         String expectedMerchantTradeNo) {
+        if (userId == null) {
+            throw business(AUTH_REQUIRED);
+        }
+        String normalizedOrderId = normalizeOrderId(orderId);
+        OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(normalizedOrderId);
+        validateOrder(userId, normalizedOrderId, order);
+        OmgPaymentAttempt active = attempts.selectActiveCreatedByDdIdForUpdate(normalizedOrderId);
+        if (active == null || active.getId() == null || expectedMerchantTradeNo == null
+                || !expectedMerchantTradeNo.equals(active.getMerchantTradeNo())) {
+            throw business(PAYMENT_RETRY_NOT_AVAILABLE, order.getStoreId());
+        }
+        PosStoreOmg credential = credentials.getEnabledCredential(order.getStoreId());
+        validateCredential(credential, order.getStoreId());
+        if (attempts.supersedeCreated(active.getId()) != 1) {
+            throw business(PAYMENT_RETRY_NOT_AVAILABLE, order.getStoreId());
+        }
+        log.info("OMG payment retry replacing attempt orderId={}, userId={}, storeId={}, oldMerchantTradeNo={}",
+                safeLogOrderId(order.getDdId()), userId, order.getStoreId(),
+                maskMerchantTradeNo(active.getMerchantTradeNo()));
+        return createWithBoundedTradeNumberRetries(order, userId, credential);
+    }
+
     private OmgPaymentCreateOutcome createWithBoundedTradeNumberRetries(
             OmgPaymentOrderSnapshot order, Long userId, PosStoreOmg credential) {
         for (int number = 1; number <= MAX_TRADE_NUMBER_ATTEMPTS; number++) {

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentErrorCode.java

@@ -15,6 +15,8 @@ public enum OmgPaymentErrorCode {
     PAYMENT_CREATION_FAILED("omg.pay.creation.failed"),
     PAYMENT_QUERY_NOT_AVAILABLE("omg.pay.query.not.available"),
     PAYMENT_QUERY_FAILED("omg.pay.query.failed"),
+    PAYMENT_RETRY_NOT_AVAILABLE("omg.pay.retry.not.available"),
+    PAYMENT_RETRY_FAILED("omg.pay.retry.failed"),
     PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT(
             "omg.pay.refund.unavailable.test.environment"),
     PAYMENT_REFUND_FAILED("omg.pay.refund.failed");

+ 45 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRetryService.java

@@ -0,0 +1,45 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse;
+import org.springframework.stereotype.Service;
+
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.ORDER_ALREADY_PAID;
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.PAYMENT_RETRY_NOT_AVAILABLE;
+
+/** Verifies the gateway state before replacing an abandoned payment attempt. */
+@Service
+public class OmgPaymentRetryService {
+    private final OmgPaymentQueryService queryService;
+    private final OmgPaymentCreateService createService;
+
+    public OmgPaymentRetryService(OmgPaymentQueryService queryService,
+                                  OmgPaymentCreateService createService) {
+        this.queryService = queryService;
+        this.createService = createService;
+    }
+
+    public OmgPaymentCreateOutcome retry(Long userId, String orderId) {
+        OmgQueryPaymentResponse query = queryService.query(userId, orderId);
+        if (query == null || query.status() == null) {
+            throw business(PAYMENT_RETRY_NOT_AVAILABLE);
+        }
+        if ("PAID".equals(query.status())) {
+            throw business(ORDER_ALREADY_PAID);
+        }
+        if ("FAILED".equals(query.status())) {
+            return createService.create(userId, orderId);
+        }
+        if ("UNPAID".equals(query.status()) && isBlank(query.paymentType())) {
+            return createService.replaceActiveForRetry(userId, orderId, query.merchantTradeNo());
+        }
+        throw business(PAYMENT_RETRY_NOT_AVAILABLE);
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.isBlank();
+    }
+
+    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code) {
+        return new OmgPaymentBusinessException(code);
+    }
+}

+ 13 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgRetryPaymentRequest.java

@@ -0,0 +1,13 @@
+package com.ruoyi.app.omgpay.dto;
+
+public class OmgRetryPaymentRequest {
+    private String orderId;
+
+    public String getOrderId() {
+        return orderId;
+    }
+
+    public void setOrderId(String orderId) {
+        this.orderId = orderId;
+    }
+}

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -240,5 +240,7 @@ omg.pay.configuration.invalid=OMG 支付配置无效
 omg.pay.creation.failed=OMG 支付创建失败,请稍后重试
 omg.pay.query.not.available=当前没有可查询的 OMG 支付信息
 omg.pay.query.failed=无法查询 OMG 支付状态,请稍后重试
+omg.pay.retry.not.available=当前支付状态无法重新发起,请先刷新支付结果
+omg.pay.retry.failed=重新发起 OMG 支付失败,请稍后重试
 omg.pay.refund.unavailable.test.environment=OMG 测试环境不支持退款,请在正式环境启用后操作
 omg.pay.refund.failed=OMG 退款操作失败,请稍后重试

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -243,5 +243,7 @@ omg.pay.configuration.invalid=The OMG Pay configuration is invalid
 omg.pay.creation.failed=The OMG Pay checkout could not be created; please try again later
 omg.pay.query.not.available=There is no current OMG payment attempt to query
 omg.pay.query.failed=The OMG payment status could not be queried; please try again later
+omg.pay.retry.not.available=The current payment state cannot be retried; refresh the payment result first
+omg.pay.retry.failed=The OMG payment could not be retried; please try again later
 omg.pay.refund.unavailable.test.environment=OMG refunds are unavailable in the test environment; enable the production environment first
 omg.pay.refund.failed=The OMG refund operation failed; please try again later

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -243,5 +243,7 @@ omg.pay.configuration.invalid=Cấu hình OMG Pay không hợp lệ
 omg.pay.creation.failed=Không thể tạo trang thanh toán OMG Pay; vui lòng thử lại sau
 omg.pay.query.not.available=Không có giao dịch thanh toán OMG hiện tại để truy vấn
 omg.pay.query.failed=Không thể truy vấn trạng thái thanh toán OMG; vui lòng thử lại sau
+omg.pay.retry.not.available=Trạng thái thanh toán hiện tại không thể thử lại; vui lòng làm mới kết quả thanh toán trước
+omg.pay.retry.failed=Không thể tạo lại thanh toán OMG; vui lòng thử lại sau
 omg.pay.refund.unavailable.test.environment=Môi trường thử nghiệm OMG không hỗ trợ hoàn tiền; hãy bật môi trường chính thức trước
 omg.pay.refund.failed=Thao tác hoàn tiền OMG thất bại; vui lòng thử lại sau

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -244,5 +244,7 @@ omg.pay.configuration.invalid=OMG 支付配置无效
 omg.pay.creation.failed=OMG 支付创建失败,请稍后重试
 omg.pay.query.not.available=当前没有可查询的 OMG 支付信息
 omg.pay.query.failed=无法查询 OMG 支付状态,请稍后重试
+omg.pay.retry.not.available=当前支付状态无法重新发起,请先刷新支付结果
+omg.pay.retry.failed=重新发起 OMG 支付失败,请稍后重试
 omg.pay.refund.unavailable.test.environment=OMG 测试环境不支持退款,请在正式环境启用后操作
 omg.pay.refund.failed=OMG 退款操作失败,请稍后重试

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -244,5 +244,7 @@ omg.pay.configuration.invalid=OMG 支付設定無效
 omg.pay.creation.failed=OMG 支付建立失敗,請稍後再試
 omg.pay.query.not.available=目前沒有可查詢的 OMG 付款資訊
 omg.pay.query.failed=無法查詢 OMG 付款狀態,請稍後再試
+omg.pay.retry.not.available=目前付款狀態無法重新發起,請先重新整理付款結果
+omg.pay.retry.failed=重新發起 OMG 付款失敗,請稍後再試
 omg.pay.refund.unavailable.test.environment=OMG 測試環境不支援退款,請在正式環境啟用後操作
 omg.pay.refund.failed=OMG 退款操作失敗,請稍後再試

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgLegacyRetirementTest.java

@@ -25,7 +25,7 @@ class OmgLegacyRetirementTest {
                 .filter(method -> method.isAnnotationPresent(PostMapping.class))
                 .flatMap(method -> Arrays.stream(method.getAnnotation(PostMapping.class).value()))
                 .collect(Collectors.toSet());
-        assertEquals(Set.of("/create", "/notify", "/query", "/refund"), postPaths);
+        assertEquals(Set.of("/create", "/notify", "/query", "/retry", "/refund"), postPaths);
         assertTrue(Arrays.stream(OmgPaymentController.class.getDeclaredMethods())
                 .map(method -> method.getName().toLowerCase())
                 .noneMatch(name -> name.contains("paymentinfo") || name.contains("return")));

+ 1 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java

@@ -142,6 +142,7 @@ class OmgPaymentControllerTest {
                                                    OmgPaymentCreateService service) {
         return new OmgPaymentController(resolver, service, mock(OmgPaymentNotifyService.class),
                 mock(OmgIpnAuditService.class), mock(OmgPaymentQueryService.class),
+                mock(OmgPaymentRetryService.class),
                 mock(OmgPaymentRefundService.class));
     }
 }

+ 48 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java

@@ -62,6 +62,54 @@ class OmgPaymentCreateServiceTest {
         verifyNoInteractions(generator, formFactory, credentials);
     }
 
+    @Test
+    void replacesOnlyTheExactVerifiedActiveAttemptAndReturnsNewForm() {
+        OmgPaymentAttempt current = new OmgPaymentAttempt();
+        current.setId(8L);
+        current.setMerchantTradeNo("OMGOLD");
+        OmgPaymentAttempt created = new OmgPaymentAttempt();
+        created.setId(9L);
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(payableOrder());
+        when(attempts.selectActiveCreatedByDdIdForUpdate("DD-1")).thenReturn(current);
+        when(credentials.getEnabledCredential(77L)).thenReturn(credential());
+        when(attempts.supersedeCreated(8L)).thenReturn(1);
+        when(generator.generate()).thenReturn("OMG12345678901234567");
+        when(formFactory.create(anyString(), anyInt(), anyString(), anyString(), anyString(), anyString()))
+                .thenReturn(new OmgPaymentForm("stage", Map.of()));
+        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString())).thenReturn(created);
+
+        OmgPaymentCreateOutcome outcome = service.replaceActiveForRetry(5L, "DD-1", "OMGOLD");
+
+        assertEquals(9L, outcome.attemptId());
+        var orderOfCalls = inOrder(attempts, credentials, generator, formFactory);
+        orderOfCalls.verify(attempts).selectOrderForUpdate("DD-1");
+        orderOfCalls.verify(attempts).selectActiveCreatedByDdIdForUpdate("DD-1");
+        orderOfCalls.verify(credentials).getEnabledCredential(77L);
+        orderOfCalls.verify(attempts).supersedeCreated(8L);
+        orderOfCalls.verify(generator).generate();
+        orderOfCalls.verify(formFactory).create("DD-1", 100, "1000031", "KEY", "IV",
+                "OMG12345678901234567");
+        orderOfCalls.verify(attempts).createCreated("DD-1", "OMG12345678901234567", 77L,
+                "1000031", 100, "KEY", "IV");
+    }
+
+    @Test
+    void rejectsRetryWhenActiveAttemptChangedAfterGatewayQuery() {
+        OmgPaymentAttempt current = new OmgPaymentAttempt();
+        current.setId(9L);
+        current.setMerchantTradeNo("OMGNEW");
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(payableOrder());
+        when(attempts.selectActiveCreatedByDdIdForUpdate("DD-1")).thenReturn(current);
+
+        OmgPaymentBusinessException error = assertThrows(OmgPaymentBusinessException.class,
+                () -> service.replaceActiveForRetry(5L, "DD-1", "OMGOLD"));
+
+        assertEquals(PAYMENT_RETRY_NOT_AVAILABLE, error.getCode());
+        verifyNoInteractions(credentials, generator, formFactory);
+        verify(attempts, never()).supersedeCreated(anyLong());
+    }
+
     @Test
     void createsAttemptAndReturnsOnlyAuthorizedFormAndSafeMetadata() {
         OmgPaymentOrderSnapshot order = payableOrder();

+ 2 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyControllerTest.java

@@ -69,7 +69,8 @@ class OmgPaymentNotifyControllerTest {
     private static OmgPaymentController controller(OmgPaymentNotifyService notifyService, OmgIpnAuditService audit) {
         return new OmgPaymentController(mock(OmgPaymentTokenUserResolver.class),
                 mock(OmgPaymentCreateService.class), notifyService, audit,
-                mock(OmgPaymentQueryService.class), mock(OmgPaymentRefundService.class));
+                mock(OmgPaymentQueryService.class), mock(OmgPaymentRetryService.class),
+                mock(OmgPaymentRefundService.class));
     }
 
     private static ListAppender<ILoggingEvent> captureLogs() {

+ 1 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryControllerTest.java

@@ -77,6 +77,7 @@ class OmgPaymentQueryControllerTest {
                                                    OmgPaymentQueryService queryService) {
         return new OmgPaymentController(resolver, mock(OmgPaymentCreateService.class),
                 mock(OmgPaymentNotifyService.class), mock(OmgIpnAuditService.class), queryService,
+                mock(OmgPaymentRetryService.class),
                 mock(OmgPaymentRefundService.class));
     }
 }

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRefundControllerTest.java

@@ -75,6 +75,6 @@ class OmgPaymentRefundControllerTest {
                                                    OmgPaymentRefundService refundService) {
         return new OmgPaymentController(resolver, mock(OmgPaymentCreateService.class),
                 mock(OmgPaymentNotifyService.class), mock(OmgIpnAuditService.class),
-                mock(OmgPaymentQueryService.class), refundService);
+                mock(OmgPaymentQueryService.class), mock(OmgPaymentRetryService.class), refundService);
     }
 }

+ 86 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRetryControllerTest.java

@@ -0,0 +1,86 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgCreatePaymentResponse;
+import com.ruoyi.app.omgpay.dto.OmgPaymentErrorResponse;
+import com.ruoyi.app.omgpay.dto.OmgRetryPaymentRequest;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.omgpay.service.OmgIpnAuditService;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+
+import java.lang.reflect.Method;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+class OmgPaymentRetryControllerTest {
+    @Test
+    void exposesAuthenticatedOrderIdOnlyRetryContractAndReturnsNewForm() throws Exception {
+        Method method = OmgPaymentController.class.getDeclaredMethod(
+                "retry", String.class, OmgRetryPaymentRequest.class);
+        assertArrayEquals(new String[]{"/retry"}, method.getAnnotation(PostMapping.class).value());
+        assertEquals("token", method.getParameters()[0].getAnnotation(RequestHeader.class).name());
+        assertNotNull(method.getParameters()[1].getAnnotation(RequestBody.class));
+
+        OmgPaymentTokenUserResolver resolver = mock(OmgPaymentTokenUserResolver.class);
+        OmgPaymentRetryService retryService = mock(OmgPaymentRetryService.class);
+        OmgCreatePaymentResponse response = new OmgCreatePaymentResponse(
+                "CREATED", "https://stage.example", Map.of("MerchantTradeNo", "OMGNEW"));
+        when(resolver.requireUserId("token")).thenReturn(5L);
+        when(retryService.retry(5L, "DD-1")).thenReturn(new OmgPaymentCreateOutcome(
+                response, 9L, "DD-1", 5L, 77L, 100, "OMGNE***0001"));
+        OmgRetryPaymentRequest request = new OmgRetryPaymentRequest();
+        request.setOrderId("DD-1");
+
+        AjaxResult result;
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+            result = controller(resolver, retryService).retry("token", request);
+        }
+
+        assertSame(response, result.get("data"));
+    }
+
+    @Test
+    void mapsRetryBusinessAndUnexpectedFailuresToStableStatus() {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+            OmgPaymentTokenUserResolver resolver = mock(OmgPaymentTokenUserResolver.class);
+            OmgPaymentRetryService retryService = mock(OmgPaymentRetryService.class);
+            when(resolver.requireUserId("token")).thenReturn(5L);
+            when(retryService.retry(5L, "DD-1")).thenThrow(
+                    new OmgPaymentBusinessException(OmgPaymentErrorCode.PAYMENT_RETRY_NOT_AVAILABLE));
+            OmgRetryPaymentRequest request = new OmgRetryPaymentRequest();
+            request.setOrderId("DD-1");
+            OmgPaymentController controller = controller(resolver, retryService);
+
+            AjaxResult business = controller.retry("token", request);
+            assertEquals("PAYMENT_RETRY_NOT_AVAILABLE",
+                    ((OmgPaymentErrorResponse) business.get("data")).status());
+
+            org.mockito.Mockito.doThrow(new IllegalStateException("db"))
+                    .when(retryService).retry(5L, "DD-1");
+            AjaxResult unexpected = controller.retry("token", request);
+            assertEquals("PAYMENT_RETRY_FAILED",
+                    ((OmgPaymentErrorResponse) unexpected.get("data")).status());
+        }
+    }
+
+    private static OmgPaymentController controller(OmgPaymentTokenUserResolver resolver,
+                                                    OmgPaymentRetryService retryService) {
+        return new OmgPaymentController(resolver, mock(OmgPaymentCreateService.class),
+                mock(OmgPaymentNotifyService.class), mock(OmgIpnAuditService.class),
+                mock(OmgPaymentQueryService.class), retryService, mock(OmgPaymentRefundService.class));
+    }
+}

+ 102 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRetryServiceTest.java

@@ -0,0 +1,102 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgCreatePaymentResponse;
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.ORDER_ALREADY_PAID;
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.PAYMENT_RETRY_NOT_AVAILABLE;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class OmgPaymentRetryServiceTest {
+    private OmgPaymentQueryService queryService;
+    private OmgPaymentCreateService createService;
+    private OmgPaymentRetryService service;
+
+    @BeforeEach
+    void setUp() {
+        queryService = mock(OmgPaymentQueryService.class);
+        createService = mock(OmgPaymentCreateService.class);
+        service = new OmgPaymentRetryService(queryService, createService);
+    }
+
+    @Test
+    void replacesVerifiedUnpaidAttemptWithoutSelectedPaymentType() {
+        OmgPaymentCreateOutcome expected = outcome();
+        when(queryService.query(5L, "DD-1")).thenReturn(query("UNPAID", "0", null));
+        when(createService.replaceActiveForRetry(5L, "DD-1", "OMGOLD"))
+                .thenReturn(expected);
+
+        OmgPaymentCreateOutcome actual = service.retry(5L, "DD-1");
+
+        assertSame(expected, actual);
+        verify(createService).replaceActiveForRetry(5L, "DD-1", "OMGOLD");
+        verify(createService, never()).create(5L, "DD-1");
+    }
+
+    @Test
+    void createsFreshAttemptAfterVerifiedGatewayFailureClosedOldAttempt() {
+        OmgPaymentCreateOutcome expected = outcome();
+        when(queryService.query(5L, "DD-1")).thenReturn(query("FAILED", "10200095", null));
+        when(createService.create(5L, "DD-1")).thenReturn(expected);
+
+        assertSame(expected, service.retry(5L, "DD-1"));
+
+        verify(createService).create(5L, "DD-1");
+        verify(createService, never()).replaceActiveForRetry(5L, "DD-1", "OMGOLD");
+    }
+
+    @Test
+    void rejectsPaidAttemptWithoutCreatingAnotherPayment() {
+        when(queryService.query(5L, "DD-1")).thenReturn(query("PAID", "1", "Credit_CreditCard"));
+
+        OmgPaymentBusinessException error = assertThrows(OmgPaymentBusinessException.class,
+                () -> service.retry(5L, "DD-1"));
+
+        assertEquals(ORDER_ALREADY_PAID, error.getCode());
+        verifyNoInteractions(createService);
+    }
+
+    @Test
+    void rejectsUnpaidAttemptThatAlreadyHasPaymentType() {
+        when(queryService.query(5L, "DD-1")).thenReturn(query("UNPAID", "0", "Credit_CreditCard"));
+
+        OmgPaymentBusinessException error = assertThrows(OmgPaymentBusinessException.class,
+                () -> service.retry(5L, "DD-1"));
+
+        assertEquals(PAYMENT_RETRY_NOT_AVAILABLE, error.getCode());
+        verifyNoInteractions(createService);
+    }
+
+    @Test
+    void rejectsUnknownGatewayStateWithoutMutatingAttempt() {
+        when(queryService.query(5L, "DD-1")).thenReturn(query("UNKNOWN", "7", null));
+
+        OmgPaymentBusinessException error = assertThrows(OmgPaymentBusinessException.class,
+                () -> service.retry(5L, "DD-1"));
+
+        assertEquals(PAYMENT_RETRY_NOT_AVAILABLE, error.getCode());
+        verifyNoInteractions(createService);
+    }
+
+    private static OmgQueryPaymentResponse query(String status, String tradeStatus, String paymentType) {
+        return new OmgQueryPaymentResponse(status, tradeStatus, "OMGOLD", "GW1", 100,
+                null, "2026/08/14 11:17:01", paymentType, "0", "0");
+    }
+
+    private static OmgPaymentCreateOutcome outcome() {
+        return new OmgPaymentCreateOutcome(
+                new OmgCreatePaymentResponse("CREATED", "https://stage.example", Map.of()),
+                9L, "DD-1", 5L, 77L, 100, "OMGNE***0001");
+    }
+}

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java

@@ -44,6 +44,9 @@ public interface OmgPaymentAttemptMapper {
 
     int markFailed(OmgPaymentAttempt row);
 
+    /** Closes exactly one still-active attempt before a verified user retry. */
+    int supersedeCreated(@Param("id") Long id, @Param("updateTime") Date updateTime);
+
     int supersedeOtherCreated(@Param("ddId") String ddId, @Param("paidAttemptId") Long paidAttemptId,
                               @Param("updateTime") Date updateTime);
 

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

@@ -38,6 +38,8 @@ public interface IOmgPaymentAttemptService {
 
     int markFailed(OmgPaymentAttempt paymentFacts);
 
+    int supersedeCreated(Long id);
+
     int supersedeOtherCreated(String ddId, Long paidAttemptId);
 
     int markOrderPaid(String ddId);

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

@@ -142,6 +142,11 @@ public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
         return mapper.markFailed(paymentFacts);
     }
 
+    @Override
+    public int supersedeCreated(Long id) {
+        return id == null ? 0 : mapper.supersedeCreated(id, new Date());
+    }
+
     @Override
     public int supersedeOtherCreated(String ddId, Long paidAttemptId) {
         return mapper.supersedeOtherCreated(ddId, paidAttemptId, new Date());

+ 6 - 0
ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml

@@ -120,6 +120,12 @@
         WHERE id = #{id} AND attempt_status IN (0, 2)
     </update>
 
+    <update id="supersedeCreated">
+        UPDATE pos_order_omg_attempt
+        SET attempt_status = 3, update_time = #{updateTime}
+        WHERE id = #{id} AND attempt_status = 0
+    </update>
+
     <update id="supersedeOtherCreated">
         UPDATE pos_order_omg_attempt
         SET attempt_status = 3, update_time = #{updateTime}

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

@@ -39,6 +39,8 @@ class OmgPaymentAttemptMapperContractTest {
         assertTrue(sql.contains("merchant_id VARCHAR(10) CHARACTER SET ascii COLLATE ascii_bin"));
         assertTrue(xml.contains("attempt_status IN (0, 2, 3)"));
         assertTrue(xml.contains("attempt_status IN (0, 2)"));
+        assertTrue(xml.contains("<update id=\"supersedeCreated\">"));
+        assertTrue(xml.contains("WHERE id = #{id} AND attempt_status = 0"));
         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"));

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

@@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
@@ -120,6 +121,14 @@ class OmgPaymentAttemptServiceTest {
                 () -> service.createCreated("DD-1", "OMG123", 10L, "M1", 100, " ", "IV"));
     }
 
+    @Test
+    void supersedeCreatedRequiresIdAndDelegatesGuardedUpdate() {
+        assertEquals(0, service.supersedeCreated(null));
+        service.supersedeCreated(7L);
+
+        verify(mapper).supersedeCreated(org.mockito.ArgumentMatchers.eq(7L), any(Date.class));
+    }
+
     private void assertLocalizedValidationFailure(String key, ThrowingRunnable runnable) {
         try (var messages = mockStatic(MessageUtils.class)) {
             messages.when(() -> MessageUtils.message(key)).thenReturn("localized-" + key);

+ 19 - 0
specs/020-omg-payment-rebuild/contracts/api.md

@@ -155,6 +155,25 @@ The server verifies ownership and chooses the only current payment attempt. An u
 
 The response whitelist includes normalized `status` (`PAID`, `UNPAID`, `FAILED`, `UNKNOWN`), raw `tradeStatus`, trade numbers, amount, gateway dates, payment type and fees. Invalid/tampered responses return `PAYMENT_QUERY_FAILED`; no eligible attempt returns `PAYMENT_QUERY_NOT_AVAILABLE`.
 
+## POST `/pay/omg/retry`
+
+Header `token` is required. The explicit JSON DTO contains only:
+
+```json
+{"orderId":"991786433092835"}
+```
+
+The endpoint is used after the App has destroyed the original payment WebView and the user explicitly starts payment again. The server never returns or replays the old form. It first executes the same authenticated, signed gateway query as `/query`:
+
+- `PAID`: return `ORDER_ALREADY_PAID` and do not create a payment.
+- `FAILED`: create a fresh attempt and form.
+- `UNPAID` with blank `paymentType`: lock the order, require the active attempt's `MerchantTradeNo` to equal the verified query result, atomically mark that exact attempt `SUPERSEDED`, and create a fresh attempt/form in the same transaction.
+- `UNPAID` with a nonblank `paymentType`, `UNKNOWN`, query failure, or a concurrent active-attempt change: do not replace the attempt and return a stable error.
+
+At most one retry request can replace a given active attempt. A late trusted paid callback for a `SUPERSEDED` attempt remains eligible for the irreversible paid transition and closes any newer active attempt.
+
+Success has the same response contract as `/create`: `{status:"CREATED", gatewayUrl, formFields}` with a new `MerchantTradeNo`. Business errors include the existing query/create errors plus `PAYMENT_RETRY_NOT_AVAILABLE`; unexpected failures return `PAYMENT_RETRY_FAILED`. The client cannot send `MerchantTradeNo`, payment type, amount, credentials, gateway URL, or old form fields.
+
 ## POST `/pay/omg/refund`
 
 Current scope is a test-environment safety boundary. Header `token` is required and the JSON body contains only:

+ 78 - 3
specs/020-omg-payment-rebuild/omg-app-integration.md

@@ -309,11 +309,11 @@ submitOmgForm(payload.gatewayUrl, payload.formFields);
 | `ORDER_AMOUNT_INVALID` | 订单金额异常 | 展示后端 `msg` |
 | `PAYMENT_TYPE_INVALID` | 订单 `payType` 不是 `"2"` | 检查创建订单时的支付方式 |
 | `STORE_CREDENTIAL_UNAVAILABLE` | 门店未启用有效 OMG 凭证 | 展示后端 `msg` |
-| `PAYMENT_ATTEMPT_EXISTS` | 已有未结束的支付尝试 | 不得再次创建;转到结果确认流程调用 query |
+| `PAYMENT_ATTEMPT_EXISTS` | 已有未结束的支付尝试 | 用户确认重新支付时调用 `/pay/omg/retry`,不要循环调用 create |
 | `PAYMENT_CONFIGURATION_INVALID` | 后端测试网关或回调配置不合法 | 展示后端 `msg`,通知后端排查 |
 | `PAYMENT_CREATION_FAILED` | 本次创建失败 | 展示后端 `msg`,稍后重试 |
 
-同一订单创建成功后,重复点击不会返回旧表单,而是返回 `PAYMENT_ATTEMPT_EXISTS`。因此支付按钮必须防重复点击;收到该状态时调用 query 确认已有尝试,不要循环调用 create
+同一订单创建成功后,重复点击不会返回旧表单,而是返回 `PAYMENT_ATTEMPT_EXISTS`。因此支付按钮必须防重复点击;如果用户只是等待支付结果,调用 query;如果用户已经退出支付页并明确再次支付,调用 retry。不要循环调用 create,也不要缓存并重新提交旧表单
 
 ## 5. 浏览器结果返回与 App Scheme
 
@@ -415,7 +415,7 @@ query 不是简单读取本地状态。后端会使用当前支付尝试的凭
 | `amount` | number | 订单金额,整数 TWD |
 | `paymentDate` | string/null | OMG 付款时间,台北时区 |
 | `tradeDate` | string/null | OMG 建单时间,台北时区 |
-| `paymentType` | string/null | OMG 实际付款方式 |
+| `paymentType` | string/null | OMG 尚未确认付款渠道时为空;完成渠道选择/付款并在 query 或回调中回传后才有值。信用卡与 Apple Pay 可能同样回传 `Credit_CreditCard`,App 不用它做二级支付方式选择 |
 | `handlingCharge` | string/null | 手续费相关原始值 |
 | `paymentTypeChargeFee` | string/null | 付款方式手续费原始值 |
 
@@ -496,6 +496,80 @@ async function confirmOmgPayment(orderId) {
 | `PAYMENT_QUERY_NOT_AVAILABLE` | 当前没有可查询的 OMG 支付尝试 | 刷新订单后展示后端 `msg` |
 | `PAYMENT_QUERY_FAILED` | OMG 查询失败、响应非法或身份校验失败 | 展示后端 `msg`,稍后人工重试 |
 
+## 6.6 退出支付页后重新支付
+
+App 返回业务页面后原支付 WebView 会销毁,本流程不要求保存或恢复 WebView。旧 `gatewayUrl + formFields` 也不能当作“继续付款网址”缓存重放:其中的 `MerchantTradeNo` 只能建单一次,重新提交会被 OMG 以重复订单编号拒绝。
+
+用户点击“重新支付”时调用:
+
+```http
+POST /pay/omg/retry
+Content-Type: application/json
+token: <用户登录 token>
+
+{
+  "orderId": "991786433092835"
+}
+```
+
+后端会先使用旧尝试的凭证快照向 OMG 查询真实状态,然后按以下规则处理:
+
+| OMG 查询结果 | 后端处理 |
+|---|---|
+| `PAID` | 不创建新支付,返回 `ORDER_ALREADY_PAID` |
+| `FAILED` | 旧尝试已经结束,生成新的 `MerchantTradeNo` 和新表单 |
+| `UNPAID` 且 `paymentType` 为空 | 原子地把旧尝试改为 `SUPERSEDED`,生成新的 `MerchantTradeNo` 和新表单 |
+| `UNPAID` 且 `paymentType` 非空 | 为避免覆盖可能正在授权的交易,返回 `PAYMENT_RETRY_NOT_AVAILABLE` |
+| `UNKNOWN`、查询失败或并发状态已改变 | 不修改旧尝试,返回对应错误 |
+
+retry 成功响应与 create 完全相同。App 收到后创建新的支付 WebView,将新的全部 `formFields` 以 Form POST 提交到新的 `gatewayUrl`:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "status": "CREATED",
+    "gatewayUrl": "https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5",
+    "formFields": {
+      "MerchantTradeNo": "OMGR8K3P7W2M9C4X6A1B"
+    }
+  }
+}
+```
+
+`paymentType` 是 OMG 查询结果,不是 App 传参,也不要求 App 增加“信用卡/Apple Pay”二级选择。用户界面仍然只有“OMG 支付”和“LINE Pay”。
+
+App 推荐处理:
+
+```js
+async function retryOmgPayment(orderId) {
+  const response = await request({
+    url: '/pay/omg/retry',
+    method: 'POST',
+    header: { token: uni.getStorageSync('token') },
+    data: { orderId }
+  });
+
+  if (response.code === 200) {
+    openNewOmgWebView(response.data.gatewayUrl, response.data.formFields);
+    return;
+  }
+  if (response.data?.status === 'ORDER_ALREADY_PAID') {
+    await confirmOmgPayment(orderId);
+    return;
+  }
+  uni.showToast({ title: response.msg, icon: 'none' });
+}
+```
+
+retry 可能返回 query、create 的既有错误,也可能返回:
+
+| `data.status` | App 处理建议 |
+|---|---|
+| `PAYMENT_RETRY_NOT_AVAILABLE` | 不自动循环;刷新订单并调用 query 确认状态 |
+| `PAYMENT_RETRY_FAILED` | 提示稍后重试,不复用旧表单 |
+
 ## 7. 后端专用接口
 
 ### 7.1 付款通知 `/pay/omg/notify`
@@ -614,6 +688,7 @@ token: <用户登录 token>
 |---|---|---|---|---|
 | 创建支付 | POST | `/pay/omg/create` | 必须 | App |
 | 查询结果 | POST | `/pay/omg/query` | 必须 | App |
+| 重新支付 | POST | `/pay/omg/retry` | 必须 | App |
 | 退款安全拒绝 | POST | `/pay/omg/refund` | 必须 | 当前 App 不调用 |
 | 付款结果通知 | POST | `/pay/omg/notify` | 不需要 | OMG 服务器 |
 | 浏览器结果返回 | POST | `/pay/omg/result` | 不需要 | OMG 收银台 |

+ 12 - 2
specs/020-omg-payment-rebuild/plan.md

@@ -1070,9 +1070,10 @@ Do not create an empty commit.
 | FR-030–FR-031 | Tasks 5 and 7 comment/log reviews and automated log assertions |
 | FR-032–FR-035 | T052–T057 DTO boundary, IPN transaction, snapshot signature and invariant validation |
 | FR-036–FR-041 | T049/T056–T059 success-priority state machine, order-only payment update and gateway facts |
-| FR-042–FR-043 | T077–T081 old utility deletion, credential verifier migration, DROP SQL and historical-record retention |
+| FR-042–FR-044 | T071–T076 test-stage refund boundary, i18n and safe logging |
+| FR-045–FR-050 | T082–T087 verified retry orchestration, exact-attempt replacement, App contract and regression tests |
 
-Self-review result: all 43 functional requirements have an implementation task and a verification point; no placeholders remain; interface names and signatures are consistent across tasks.
+Self-review result: all 50 functional requirements have an implementation task and a verification point; no placeholders remain; interface names and signatures are consistent across tasks.
 
 ## Risks and Controls
 
@@ -1117,3 +1118,12 @@ No constitution violations. The separate form factory, signer, generator and per
 - 删除旧 `omg.base-url` 配置;stage 查询地址继续由 rebuild 网关固定控制,不接受客户端或旧配置覆盖。
 - 仅在 `updatesql/sql.md` 追加按依赖顺序删除 `pos_order_omg_refund`、`pos_order_omg_payment` 的 SQL,不在实现会话中执行;保留 `pos_store_omg`、`pos_order_omg_attempt` 和 `ipn_log`。
 - 保留 `specs/016-omg-payment` 与历史建表 SQL 作为审计记录,但它们不再定义当前实现。
+
+## 2026-08-14 已销毁 WebView 的重新支付
+
+1. App 首次付款仍调用 `/pay/omg/create`;退出支付页后不保存、不恢复原 WebView,也不重放旧表单。
+2. 用户明确再次付款时调用 `/pay/omg/retry`。retry 先通过可信 `/query` 链路向 OMG 核实旧交易,客户端不能指定交易号或支付渠道。
+3. `PAID` 直接拒绝新建;`FAILED` 在旧尝试已释放后正常创建;`UNPAID` 仅允许 `paymentType` 为空的尝试被替换;其他状态 fail closed。
+4. `UNPAID` 替换在独立写事务中锁订单并再次验证订单,要求活动尝试交易号与 query 结果精确一致,然后以 `id + CREATED` 条件更新为 `SUPERSEDED` 并插入新尝试。
+5. 两个并发 retry 即使都查到同一旧交易,也只有先取得订单锁者能替换;后取得者看到活动交易号变化后返回 `PAYMENT_RETRY_NOT_AVAILABLE`。
+6. retry 成功复用 create 响应,返回新 `MerchantTradeNo` 的新表单。无需数据库结构变更,现有 `attempt_status=3` 与生成列唯一键已覆盖该流程。

+ 8 - 1
specs/020-omg-payment-rebuild/spec.md

@@ -158,6 +158,12 @@ OMG 向 `ReturnURL` 发送最终付款结果时,系统保存本次 HTTP 回传
 - **FR-042**: `POST /pay/omg/refund` MUST 需要 token,并以显式 JSON DTO 只接收 `orderId`;不得接收客户端提供的金额、交易号、Action、凭证或地址。
 - **FR-043**: 当前测试阶段退款 MUST 返回 `PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT`,MUST NOT 调用 OMG 正式 `CreditDetail/DoAction`、查询或写入任何订单/支付/退款状态。
 - **FR-044**: 测试环境退款拒绝 MUST 提供五套 i18n 提示和必要的脱敏日志;日志不得记录 token、凭证或完整支付签名。
+- **FR-045**: 系统 MUST 提供 `POST /pay/omg/retry`,使用 token 和只含 `orderId` 的显式 JSON DTO;客户端不得提交旧 `MerchantTradeNo`、支付方式、金额、凭证、网关地址或旧表单。
+- **FR-046**: retry MUST 先复用可信 query 查询 OMG 实际状态;查询失败、响应不可信或状态为 `UNKNOWN` 时不得修改尝试或创建新表单。
+- **FR-047**: query 确认 `PAID` 时 retry MUST 返回 `ORDER_ALREADY_PAID`;确认 `FAILED` 时 MAY 创建新尝试;确认 `UNPAID` 时仅当 `paymentType` 为空才 MAY 替换旧尝试。
+- **FR-048**: 替换未付款尝试 MUST 在同一事务内锁定订单,精确比较 query 已验证的 `MerchantTradeNo` 与当前活动尝试,把该行原子更新为 `SUPERSEDED` 后再生成新交易号和新表单;任一条件变化 MUST 返回 `PAYMENT_RETRY_NOT_AVAILABLE`。
+- **FR-049**: 同一旧尝试的顺序或并发 retry MUST 最多生成一条新 `CREATED` 尝试;迟到的旧尝试可信成功回调仍 MUST 能升级为 `PAID` 并关闭更新的活动尝试。
+- **FR-050**: retry 不保存、恢复或复用 App WebView,不返回旧表单或所谓继续付款 URL;成功响应 MUST 与 create 相同并包含新 `MerchantTradeNo` 的新表单。
 
 - **FR-001**: 系统 MUST 新建 `com.ruoyi.app.omgpay` 下的 Controller、请求/响应 DTO、创建服务、表单生成器、签名器和配置类型;这些新类 MUST NOT 引用旧 `OmgPayController`、旧 `OmgPay`、旧 `OmgCheckMacValue` 或旧 OMG 支付流水服务。
 - **FR-002**: 系统 MUST 新建 `com.ruoyi.system.omgpay` 下的支付尝试 Entity、Mapper 和 Service;新支付尝试 MUST 使用 `pos_order_omg_attempt`,不得读取或写入旧 OMG 支付流水表。
@@ -364,6 +370,7 @@ token: <login-token>
 - **SC-010**: 创建开始、创建成功、业务拒绝和非预期异常均可通过业务订单号及安全的支付尝试上下文定位;敏感信息日志泄露数为 0,非预期异常堆栈保留率为 100%。
 - **SC-011**: 新 `omgpay` 的公开类型、签名编码、事务和数据库并发边界均有必要注释,显然样板代码上的重复注释数为 0。
 - **SC-012**: 生产源码中旧 `com.ruoyi.app.utils.omg` 类定义、导入和调用数为 0;旧支付/退款表没有运行 SQL,测试源码只允许用类名或表名做退役断言。
+- **SC-013**: 已验证的 `UNPAID + paymentType 为空` 重试 100% 使用新 `MerchantTradeNo`;同一旧尝试并发重试时新活动尝试数不超过 1,旧表单重放次数为 0。
 
 ## Assumptions
 
@@ -372,7 +379,7 @@ token: <login-token>
 - `pos_store_omg` 的既有启用凭证查询能按 `storeId` 返回正确门店凭证。
 - 测试环境使用 OMG 官方公开的 AIO stage 端点;正式环境切换将在可信回调及后续流程完成后单独设计和批准。
 - 新表 SQL 由开发者手动执行;代码实现不会连接数据库执行 DDL。
-- 第一阶段测试人员接受:一旦某订单已有 `CREATED` 尝试,在可信查询阶段完成前,需要人工准备新业务订单才能再次测试创建;系统不会自动清理或换号
+- App 退出原支付页面后会销毁 WebView;重新支付必须调用 retry 获取新表单,不要求保存或恢复原 WebView
 
 ## Official Sources
 

+ 9 - 0
specs/020-omg-payment-rebuild/tasks.md

@@ -136,6 +136,15 @@
 - [x] T080 在 `updatesql/sql.md` 追加删除 `pos_order_omg_refund`、`pos_order_omg_payment` 的 SQL,不执行数据库变更
 - [x] T081 静态检查源码旧包/旧表引用为零,并用 JDK 21 运行完整 Maven 回归
 
+## Phase 14: App 销毁 WebView 后重新支付
+
+- [x] T082 [US7] 先编写 retry Service、Controller、精确活动尝试替换与并发状态变化测试,并观察当前实现因缺少接口和行为而失败
+- [x] T083 [US7] 扩展 Attempt Mapper/Service,以 `id + CREATED` 条件原子更新单条尝试为 `SUPERSEDED`;复用现有表结构,不新增或执行 SQL
+- [x] T084 [US7] 在创建服务实现订单锁、订单再次校验、已验证 `MerchantTradeNo` 精确比较、旧尝试关闭与新表单创建的同事务替换
+- [x] T085 [US7] 实现 `OmgPaymentRetryService` 与 `POST /pay/omg/retry`,按 `PAID/FAILED/UNPAID/UNKNOWN` 安全分流并增加五语言错误状态
+- [x] T086 [US7] 更新 API 契约与 App 接入文档,明确不保存/恢复 WebView、不重放旧表单,App 仍只有 OMG/LINE Pay 顶层选择
+- [x] T087 使用 JDK 21 运行 retry、OMG 回归、模块构建与最终 diff/暂存范围检查
+
 ## Dependencies & Execution Order
 
 - Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 → Phase 8 → Phase 9 → Phase 10。