소스 검색

feat: add OMG payment query compensation

qmj 2 주 전
부모
커밋
b5a4d5fec8
34개의 변경된 파일1028개의 추가작업 그리고 51개의 파일을 삭제
  1. 4 2
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgCheckMacSigner.java
  2. 28 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java
  3. 3 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentErrorCode.java
  4. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFactSource.java
  5. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentGatewayFacts.java
  6. 98 39
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  7. 56 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryGateway.java
  8. 258 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java
  9. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentSettlementResult.java
  10. 40 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgQueryResponseParser.java
  11. 14 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgQueryPaymentRequest.java
  12. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgQueryPaymentResponse.java
  13. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  14. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  15. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  16. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  17. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  18. 32 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgCheckMacSignerTest.java
  19. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java
  20. 2 1
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyControllerTest.java
  21. 34 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java
  22. 76 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryControllerTest.java
  23. 176 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryServiceTest.java
  24. 34 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgQueryResponseParserTest.java
  25. 2 1
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentAttempt.java
  26. 9 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java
  27. 6 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java
  28. 15 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java
  29. 24 0
      ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml
  30. 19 0
      specs/020-omg-payment-rebuild/contracts/api.md
  31. 1 1
      specs/020-omg-payment-rebuild/data-model.md
  32. 14 2
      specs/020-omg-payment-rebuild/plan.md
  33. 12 1
      specs/020-omg-payment-rebuild/spec.md
  34. 11 1
      specs/020-omg-payment-rebuild/tasks.md

+ 4 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgCheckMacSigner.java

@@ -34,7 +34,7 @@ public class OmgCheckMacSigner {
 
     private static TreeMap<String, String> validatedCopy(Map<String, String> fields) {
         Objects.requireNonNull(fields, "fields must not be null");
-        TreeMap<String, String> sorted = new TreeMap<>();
+        TreeMap<String, String> sorted = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
         for (Map.Entry<String, String> entry : fields.entrySet()) {
             String key = entry.getKey();
             String value = entry.getValue();
@@ -43,7 +43,9 @@ public class OmgCheckMacSigner {
             if ("CheckMacValue".equals(key)) {
                 throw new IllegalArgumentException("CheckMacValue must not be supplied");
             }
-            sorted.put(key, value);
+            if (sorted.put(key, value) != null) {
+                throw new IllegalArgumentException("duplicate field key ignoring case");
+            }
         }
         return sorted;
     }

+ 28 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.omgpay;
 import com.ruoyi.app.omgpay.dto.OmgCreatePaymentRequest;
 import com.ruoyi.app.omgpay.dto.OmgPaymentErrorResponse;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentRequest;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.MessageUtils;
@@ -29,15 +30,18 @@ public class OmgPaymentController {
     private final OmgPaymentCreateService createService;
     private final OmgPaymentNotifyService notifyService;
     private final OmgIpnAuditService ipnAuditService;
+    private final OmgPaymentQueryService queryService;
 
     public OmgPaymentController(OmgPaymentTokenUserResolver tokenUserResolver,
                                 OmgPaymentCreateService createService,
                                 OmgPaymentNotifyService notifyService,
-                                OmgIpnAuditService ipnAuditService) {
+                                OmgIpnAuditService ipnAuditService,
+                                OmgPaymentQueryService queryService) {
         this.tokenUserResolver = tokenUserResolver;
         this.createService = createService;
         this.notifyService = notifyService;
         this.ipnAuditService = ipnAuditService;
+        this.queryService = queryService;
     }
 
     /** OMG server-to-server final payment notification. */
@@ -102,6 +106,29 @@ public class OmgPaymentController {
         }
     }
 
+    @Anonymous
+    @Auth
+    @PostMapping("/query")
+    public AjaxResult query(@RequestHeader(name = "token") String token,
+                            @RequestBody(required = false) OmgQueryPaymentRequest request) {
+        String orderId = request == null ? null : request.getOrderId();
+        String safeOrderId = safeLogOrderId(orderId);
+        Long safeUserId = null;
+        try {
+            safeUserId = tokenUserResolver.requireUserId(token);
+            return AjaxResult.success(queryService.query(safeUserId, orderId));
+        } catch (OmgPaymentBusinessException error) {
+            log.warn("OMG payment query 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 query failed orderId={}, userId={}", safeOrderId, safeUserId, error);
+            return AjaxResult.error(MessageUtils.message("omg.pay.query.failed"),
+                    new OmgPaymentErrorResponse("PAYMENT_QUERY_FAILED"));
+        }
+    }
+
     static String safeLogOrderId(String orderId) {
         if (orderId == null) {
             return "<empty>";

+ 3 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentErrorCode.java

@@ -12,7 +12,9 @@ public enum OmgPaymentErrorCode {
     STORE_CREDENTIAL_UNAVAILABLE("omg.pay.credential.unavailable"),
     PAYMENT_ATTEMPT_EXISTS("omg.pay.attempt.exists"),
     PAYMENT_CONFIGURATION_INVALID("omg.pay.configuration.invalid"),
-    PAYMENT_CREATION_FAILED("omg.pay.creation.failed");
+    PAYMENT_CREATION_FAILED("omg.pay.creation.failed"),
+    PAYMENT_QUERY_NOT_AVAILABLE("omg.pay.query.not.available"),
+    PAYMENT_QUERY_FAILED("omg.pay.query.failed");
 
     private final String messageKey;
 

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFactSource.java

@@ -0,0 +1,7 @@
+package com.ruoyi.app.omgpay;
+
+/** Trusted gateway channel from which normalized payment facts originated. */
+public enum OmgPaymentFactSource {
+    NOTIFY,
+    QUERY
+}

+ 20 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentGatewayFacts.java

@@ -0,0 +1,20 @@
+package com.ruoyi.app.omgpay;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** Verified gateway facts shared by callback settlement and query compensation. */
+public record OmgPaymentGatewayFacts(
+        OmgPaymentFactSource source,
+        String merchantId,
+        String merchantTradeNo,
+        int amount,
+        int resultCode,
+        String resultMessage,
+        String tradeNo,
+        String paymentType,
+        Date paymentDate,
+        Date tradeDate,
+        BigDecimal paymentTypeChargeFee,
+        Integer simulatePaid) {
+}

+ 98 - 39
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -9,6 +9,7 @@ import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.time.LocalDateTime;
@@ -74,35 +75,67 @@ public class OmgPaymentNotifyService {
             log.warn("OMG notify rejected merchantTradeNo={} reason=invalid_gateway_facts", merchantTradeNo);
             return false;
         }
-        if (facts.rtnCode != 1) {
+        OmgPaymentGatewayFacts gatewayFacts = new OmgPaymentGatewayFacts(
+                OmgPaymentFactSource.NOTIFY, attempt.getMerchantId(), merchantTradeNo,
+                attempt.getAmount(), facts.rtnCode, facts.rtnMsg, facts.tradeNo,
+                facts.paymentType, facts.paymentDate, facts.tradeDate,
+                facts.paymentTypeChargeFee, facts.simulatePaid);
+        return applyVerifiedFacts(order, attempt, gatewayFacts) != null;
+    }
+
+    /**
+     * Applies a signed QueryTradeInfo result so a lost callback cannot leave a paid order unpaid.
+     * The row locks and irreversible state rules are identical to the callback path.
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public OmgPaymentSettlementResult synchronizeVerifiedQuery(OmgPaymentGatewayFacts facts) {
+        if (facts == null || facts.source() != OmgPaymentFactSource.QUERY) {
+            throw new IllegalArgumentException("verified OMG query facts are required");
+        }
+        OmgPaymentAttempt discovered = attempts.selectByMerchantTradeNo(facts.merchantTradeNo());
+        if (discovered == null || !sameIdentity(facts, discovered)) {
+            throw new IllegalArgumentException("OMG query facts do not identify an attempt");
+        }
+        OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId());
+        OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(facts.merchantTradeNo());
+        if (attempt == null || !sameIdentity(facts, attempt)) {
+            throw new IllegalArgumentException("OMG query attempt changed while locking");
+        }
+        return applyVerifiedFacts(order, attempt, facts);
+    }
+
+    private OmgPaymentSettlementResult applyVerifiedFacts(OmgPaymentOrderSnapshot order,
+                                                          OmgPaymentAttempt attempt,
+                                                          OmgPaymentGatewayFacts facts) {
+        boolean paid = facts.resultCode() == 1;
+        if (!paid) {
             if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
-                log.warn("OMG notify ignored late failure merchantTradeNo={}, rtnCode={}, rtnMsg={}",
-                        merchantTradeNo, facts.rtnCode, facts.rtnMsg);
-                return true;
+                log.warn("OMG {} ignored late failure merchantTradeNo={}, code={}, message={}",
+                        facts.source(), attempt.getMerchantTradeNo(), facts.resultCode(), facts.resultMessage());
+                return OmgPaymentSettlementResult.PAID;
             }
             if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_SUPERSEDED) {
-                log.warn("OMG notify accepted failure for superseded attempt merchantTradeNo={}, "
-                                + "rtnCode={}, rtnMsg={}", merchantTradeNo, facts.rtnCode, facts.rtnMsg);
-                return true;
+                log.warn("OMG {} accepted failure for superseded attempt merchantTradeNo={}, code={}",
+                        facts.source(), attempt.getMerchantTradeNo(), facts.resultCode());
+                return OmgPaymentSettlementResult.FAILED;
             }
             requireSingleUpdate(attempts.markFailed(toUpdate(attempt, facts)), "mark failed");
-            log.warn("OMG payment failed merchantTradeNo={}, tradeNo={}, rtnCode={}, rtnMsg={}",
-                    merchantTradeNo, facts.tradeNo, facts.rtnCode, facts.rtnMsg);
-            return true;
+            log.warn("OMG payment failed source={}, merchantTradeNo={}, tradeNo={}, code={}, message={}",
+                    facts.source(), attempt.getMerchantTradeNo(), facts.tradeNo(),
+                    facts.resultCode(), facts.resultMessage());
+            return OmgPaymentSettlementResult.FAILED;
         }
 
         if (order == null) {
-            log.error("OMG paid notify rejected merchantTradeNo={} reason=order_not_found", merchantTradeNo);
-            return false;
+            throw new IllegalStateException("OMG paid fact has no order");
         }
         if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
-            if (attempt.getTradeNo() != null && !attempt.getTradeNo().equals(facts.tradeNo)) {
-                log.error("OMG paid duplicate conflict merchantTradeNo={}, storedTradeNo={}, callbackTradeNo={}",
-                        merchantTradeNo, attempt.getTradeNo(), facts.tradeNo);
-                return false;
+            if (attempt.getTradeNo() != null && !attempt.getTradeNo().equals(facts.tradeNo())) {
+                throw new IllegalStateException("OMG paid fact conflicts with stored TradeNo");
             }
-            log.info("OMG paid duplicate accepted merchantTradeNo={}, tradeNo={}", merchantTradeNo, facts.tradeNo);
-            return true;
+            log.info("OMG paid duplicate accepted source={}, merchantTradeNo={}, tradeNo={}",
+                    facts.source(), attempt.getMerchantTradeNo(), facts.tradeNo());
+            return OmgPaymentSettlementResult.PAID;
         }
 
         requireSingleUpdate(attempts.markPaid(toUpdate(attempt, facts)), "mark paid");
@@ -112,16 +145,19 @@ public class OmgPaymentNotifyService {
         }
         int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
         if (order.getState() != null && order.getState() == 4L) {
-            log.error("OMG paid after order cancellation orderId={}, merchantTradeNo={}, tradeNo={}, simulatePaid={}",
-                    attempt.getDdId(), merchantTradeNo, facts.tradeNo, facts.simulatePaid);
+            log.error("OMG paid after order cancellation source={}, orderId={}, merchantTradeNo={}, tradeNo={}",
+                    facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(), facts.tradeNo());
         }
         if (otherPaid > 0) {
-            log.error("OMG multiple paid attempts orderId={}, merchantTradeNo={}, tradeNo={}, otherPaidCount={}",
-                    attempt.getDdId(), merchantTradeNo, facts.tradeNo, otherPaid);
+            log.error("OMG multiple paid attempts source={}, orderId={}, merchantTradeNo={}, "
+                            + "tradeNo={}, otherPaidCount={}",
+                    facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(),
+                    facts.tradeNo(), otherPaid);
         }
-        log.info("OMG payment marked paid orderId={}, merchantTradeNo={}, tradeNo={}, amount={}, simulatePaid={}",
-                attempt.getDdId(), merchantTradeNo, facts.tradeNo, attempt.getAmount(), facts.simulatePaid);
-        return true;
+        log.info("OMG payment marked paid source={}, orderId={}, merchantTradeNo={}, tradeNo={}, amount={}",
+                facts.source(), attempt.getDdId(), attempt.getMerchantTradeNo(),
+                facts.tradeNo(), attempt.getAmount());
+        return OmgPaymentSettlementResult.PAID;
     }
 
     private boolean verifyTrust(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
@@ -138,8 +174,7 @@ public class OmgPaymentNotifyService {
         }
         String expected = signer.sign(request.signingFields(),
                 attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot());
-        return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
-                actual.toUpperCase().getBytes(StandardCharsets.US_ASCII));
+        return secureEquals(expected, actual);
     }
 
     private static boolean factsDoNotIdentifySameAttempt(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
@@ -154,8 +189,8 @@ public class OmgPaymentNotifyService {
 
     private static ParsedFacts parseFacts(OmgNotifyRequest request) {
         Integer rtnCode = requiredInteger(request.value("RtnCode"));
-        Integer fee = requiredInteger(request.value("PaymentTypeChargeFee"));
-        if (fee < 0) {
+        BigDecimal fee = requiredDecimal(request.value("PaymentTypeChargeFee"));
+        if (fee.signum() < 0) {
             throw new IllegalArgumentException("invalid PaymentTypeChargeFee");
         }
         Integer simulatePaid = requiredInteger(request.value("SimulatePaid"));
@@ -183,22 +218,34 @@ public class OmgPaymentNotifyService {
                 paymentDate, tradeDate, fee, simulatePaid);
     }
 
-    private static OmgPaymentAttempt toUpdate(OmgPaymentAttempt attempt, ParsedFacts facts) {
+    private static OmgPaymentAttempt toUpdate(OmgPaymentAttempt attempt, OmgPaymentGatewayFacts facts) {
         OmgPaymentAttempt update = new OmgPaymentAttempt();
         update.setId(attempt.getId());
-        update.setTradeNo(facts.tradeNo);
-        update.setRtnCode(facts.rtnCode);
-        update.setRtnMsg(limit(facts.rtnMsg, 200));
-        update.setPaymentType(limit(facts.paymentType, 20));
-        update.setPaymentDate(facts.paymentDate);
-        update.setTradeDate(facts.tradeDate);
-        update.setPaymentTypeChargeFee(facts.paymentTypeChargeFee);
-        update.setSimulatePaid(facts.simulatePaid);
+        update.setTradeNo(facts.tradeNo());
+        update.setRtnCode(facts.resultCode());
+        update.setRtnMsg(limit(facts.resultMessage(), 200));
+        update.setPaymentType(limit(facts.paymentType(), 20));
+        update.setPaymentDate(facts.paymentDate());
+        update.setTradeDate(facts.tradeDate());
+        update.setPaymentTypeChargeFee(facts.paymentTypeChargeFee());
+        update.setSimulatePaid(facts.simulatePaid());
         update.setLastNotifyTime(new Date());
         update.setUpdateTime(new Date());
         return update;
     }
 
+    private static boolean sameIdentity(OmgPaymentGatewayFacts facts, OmgPaymentAttempt attempt) {
+        return facts.merchantTradeNo().equals(attempt.getMerchantTradeNo())
+                && facts.merchantId().equals(attempt.getMerchantId())
+                && facts.amount() == attempt.getAmount();
+    }
+
+    static boolean secureEquals(String expected, String actual) {
+        return expected != null && actual != null
+                && MessageDigest.isEqual(expected.toUpperCase().getBytes(StandardCharsets.US_ASCII),
+                actual.toUpperCase().getBytes(StandardCharsets.US_ASCII));
+    }
+
     private static Date parseDate(String value, boolean required) {
         if (isBlank(value)) {
             if (required) {
@@ -221,6 +268,17 @@ public class OmgPaymentNotifyService {
         return parsed;
     }
 
+    private static BigDecimal requiredDecimal(String value) {
+        if (isBlank(value)) {
+            throw new IllegalArgumentException("invalid decimal");
+        }
+        try {
+            return new BigDecimal(value);
+        } catch (NumberFormatException exception) {
+            throw new IllegalArgumentException("invalid decimal", exception);
+        }
+    }
+
     private static Integer parseInteger(String value) {
         try {
             return isBlank(value) ? null : Integer.valueOf(value);
@@ -244,6 +302,7 @@ public class OmgPaymentNotifyService {
     }
 
     private record ParsedFacts(int rtnCode, String rtnMsg, String tradeNo, String paymentType,
-                               Date paymentDate, Date tradeDate, int paymentTypeChargeFee, int simulatePaid) {
+                               Date paymentDate, Date tradeDate,
+                               BigDecimal paymentTypeChargeFee, int simulatePaid) {
     }
 }

+ 56 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryGateway.java

@@ -0,0 +1,56 @@
+package com.ruoyi.app.omgpay;
+
+import org.apache.http.NameValuePair;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** Stage-only HTTP boundary for OMG QueryTradeInfo V5. */
+@Component
+public class OmgPaymentQueryGateway {
+    static final String STAGE_QUERY_URL =
+            "https://payment-stage.funpoint.com.tw/Cashier/QueryTradeInfo/V5";
+    private static final int MAX_RESPONSE_BYTES = 32 * 1024;
+    private static final RequestConfig REQUEST_CONFIG = RequestConfig.custom()
+            .setConnectTimeout(5_000)
+            .setConnectionRequestTimeout(5_000)
+            .setSocketTimeout(10_000)
+            .build();
+
+    public String query(Map<String, String> fields) {
+        List<NameValuePair> form = new ArrayList<>();
+        fields.forEach((name, value) -> form.add(new BasicNameValuePair(name, value)));
+        HttpPost post = new HttpPost(STAGE_QUERY_URL);
+        post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8));
+        try (CloseableHttpClient client = HttpClients.custom()
+                .setDefaultRequestConfig(REQUEST_CONFIG)
+                .disableRedirectHandling()
+                .build();
+             CloseableHttpResponse response = client.execute(post)) {
+            int status = response.getStatusLine().getStatusCode();
+            if (status < 200 || status >= 300 || response.getEntity() == null
+                    || response.getEntity().getContentLength() > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG query returned an invalid HTTP response");
+            }
+            byte[] bytes = EntityUtils.toByteArray(response.getEntity());
+            if (bytes.length > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG query response is oversized");
+            }
+            return new String(bytes, StandardCharsets.UTF_8);
+        } catch (IOException exception) {
+            throw new IllegalStateException("OMG query request failed", exception);
+        }
+    }
+}

+ 258 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java

@@ -0,0 +1,258 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.*;
+
+/** Queries the one current payment attempt and compensates for a lost paid callback. */
+@Service
+public class OmgPaymentQueryService {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentQueryService.class);
+    private static final int ATTEMPT_PAID = 1;
+    private static final String TRADE_UNPAID = "0";
+    private static final String TRADE_PAID = "1";
+    private static final String TRADE_FAILED = "10200095";
+    private static final DateTimeFormatter OMG_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
+    private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
+    private static final List<String> REQUIRED_FIELDS = List.of(
+            "MerchantID", "MerchantTradeNo", "StoreID", "TradeNo", "TradeAmt",
+            "PaymentDate", "PaymentType", "HandlingCharge", "PaymentTypeChargeFee",
+            "TradeDate", "TradeStatus", "ItemName", "CustomField1", "CustomField2",
+            "CustomField3", "CustomField4", "CheckMacValue");
+
+    private final IOmgPaymentAttemptService attempts;
+    private final OmgPaymentQueryGateway gateway;
+    private final OmgQueryResponseParser parser;
+    private final OmgCheckMacSigner signer;
+    private final OmgPaymentNotifyService settlement;
+    private final Clock clock;
+
+    @Autowired
+    public OmgPaymentQueryService(IOmgPaymentAttemptService attempts,
+                                  OmgPaymentQueryGateway gateway,
+                                  OmgQueryResponseParser parser,
+                                  OmgCheckMacSigner signer,
+                                  OmgPaymentNotifyService settlement) {
+        this(attempts, gateway, parser, signer, settlement, Clock.systemUTC());
+    }
+
+    OmgPaymentQueryService(IOmgPaymentAttemptService attempts,
+                           OmgPaymentQueryGateway gateway,
+                           OmgQueryResponseParser parser,
+                           OmgCheckMacSigner signer,
+                           OmgPaymentNotifyService settlement,
+                           Clock clock) {
+        this.attempts = attempts;
+        this.gateway = gateway;
+        this.parser = parser;
+        this.signer = signer;
+        this.settlement = settlement;
+        this.clock = clock;
+    }
+
+    public OmgQueryPaymentResponse query(Long userId, String orderId) {
+        if (userId == null) {
+            throw business(AUTH_REQUIRED);
+        }
+        String normalizedOrderId = normalizeOrderId(orderId);
+        OmgPaymentOrderSnapshot order = attempts.selectOrder(normalizedOrderId);
+        if (order == null || !userId.equals(order.getUserId())) {
+            throw business(ORDER_NOT_AVAILABLE);
+        }
+        OmgPaymentAttempt attempt = selectCurrentAttempt(order);
+        validateAttempt(attempt, order.getStoreId());
+        log.info("OMG payment query started orderId={}, userId={}, storeId={}, merchantTradeNo={}",
+                OmgPaymentController.safeLogOrderId(order.getDdId()), userId, order.getStoreId(),
+                OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()));
+
+        try {
+            Map<String, String> response = parser.parse(gateway.query(buildRequest(attempt)));
+            validateResponse(response, attempt);
+            String tradeStatus = response.get("TradeStatus");
+            String status = synchronizeIfFinal(tradeStatus, response, attempt);
+            log.info("OMG payment query completed orderId={}, merchantTradeNo={}, tradeNo={}, "
+                            + "gatewayTradeStatus={}, localStatus={}",
+                    OmgPaymentController.safeLogOrderId(order.getDdId()),
+                    OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()),
+                    maskTradeNo(response.get("TradeNo")), tradeStatus, status);
+            return toResponse(status, response);
+        } catch (OmgPaymentBusinessException exception) {
+            throw exception;
+        } catch (Exception exception) {
+            log.error("OMG payment query failed orderId={}, merchantTradeNo={}",
+                    OmgPaymentController.safeLogOrderId(order.getDdId()),
+                    OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), exception);
+            throw business(PAYMENT_QUERY_FAILED, order.getStoreId());
+        }
+    }
+
+    private OmgPaymentAttempt selectCurrentAttempt(OmgPaymentOrderSnapshot order) {
+        if (order.getPayStatus() != null && order.getPayStatus() == 1L) {
+            return attempts.selectPaidByDdId(order.getDdId());
+        }
+        return attempts.selectActiveCreatedByDdId(order.getDdId());
+    }
+
+    private Map<String, String> buildRequest(OmgPaymentAttempt attempt) {
+        LinkedHashMap<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", attempt.getMerchantId());
+        fields.put("MerchantTradeNo", attempt.getMerchantTradeNo());
+        fields.put("TimeStamp", String.valueOf(clock.instant().getEpochSecond()));
+        fields.put("CheckMacValue", signer.sign(fields,
+                attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()));
+        return fields;
+    }
+
+    private void validateResponse(Map<String, String> response, OmgPaymentAttempt attempt) {
+        if (!REQUIRED_FIELDS.stream().allMatch(response::containsKey)) {
+            throw new IllegalArgumentException("OMG query response has missing fields");
+        }
+        String checkMacValue = response.get("CheckMacValue");
+        if (checkMacValue == null || !checkMacValue.matches("(?i)[0-9a-f]{64}")) {
+            throw new IllegalArgumentException("OMG query response signature is malformed");
+        }
+        LinkedHashMap<String, String> signingFields = new LinkedHashMap<>(response);
+        signingFields.remove("CheckMacValue");
+        String expected = signer.sign(signingFields,
+                attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot());
+        if (!OmgPaymentNotifyService.secureEquals(expected, checkMacValue)) {
+            throw new IllegalArgumentException("OMG query response signature mismatch");
+        }
+        if (!attempt.getMerchantId().equals(response.get("MerchantID"))
+                || !attempt.getMerchantTradeNo().equals(response.get("MerchantTradeNo"))
+                || !String.valueOf(attempt.getAmount()).equals(response.get("TradeAmt"))) {
+            throw new IllegalArgumentException("OMG query response identity mismatch");
+        }
+    }
+
+    private String synchronizeIfFinal(String tradeStatus, Map<String, String> fields,
+                                      OmgPaymentAttempt attempt) {
+        if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == ATTEMPT_PAID
+                && !TRADE_PAID.equals(tradeStatus)) {
+            log.error("OMG query cannot downgrade paid attempt merchantTradeNo={}, gatewayTradeStatus={}",
+                    OmgPaymentCreateService.maskMerchantTradeNo(attempt.getMerchantTradeNo()), tradeStatus);
+            return "PAID";
+        }
+        if (TRADE_UNPAID.equals(tradeStatus)) {
+            return "UNPAID";
+        }
+        if (!TRADE_PAID.equals(tradeStatus) && !TRADE_FAILED.equals(tradeStatus)) {
+            return "UNKNOWN";
+        }
+        OmgPaymentSettlementResult result = settlement.synchronizeVerifiedQuery(
+                toGatewayFacts(fields, attempt, Integer.parseInt(tradeStatus)));
+        return result.name();
+    }
+
+    private static OmgPaymentGatewayFacts toGatewayFacts(Map<String, String> fields,
+                                                         OmgPaymentAttempt attempt,
+                                                         int tradeStatus) {
+        boolean paid = tradeStatus == 1;
+        String tradeNo = blankToNull(fields.get("TradeNo"));
+        if (paid && tradeNo == null) {
+            throw new IllegalArgumentException("paid query response has no TradeNo");
+        }
+        String paymentType = blankToNull(fields.get("PaymentType"));
+        if (paid && paymentType == null) {
+            throw new IllegalArgumentException("paid query response has no PaymentType");
+        }
+        BigDecimal fee = parseDecimal(fields.get("PaymentTypeChargeFee"));
+        if (fee == null || fee.signum() < 0) {
+            throw new IllegalArgumentException("invalid payment fee");
+        }
+        return new OmgPaymentGatewayFacts(OmgPaymentFactSource.QUERY,
+                attempt.getMerchantId(), attempt.getMerchantTradeNo(), attempt.getAmount(),
+                tradeStatus, "TradeStatus=" + tradeStatus, tradeNo, paymentType,
+                parseDate(fields.get("PaymentDate"), paid),
+                parseDate(fields.get("TradeDate"), true), fee, null);
+    }
+
+    private static OmgQueryPaymentResponse toResponse(String status, Map<String, String> fields) {
+        return new OmgQueryPaymentResponse(status, fields.get("TradeStatus"),
+                fields.get("MerchantTradeNo"), blankToNull(fields.get("TradeNo")),
+                Integer.valueOf(fields.get("TradeAmt")), blankToNull(fields.get("PaymentDate")),
+                blankToNull(fields.get("TradeDate")), blankToNull(fields.get("PaymentType")),
+                fields.get("HandlingCharge"), fields.get("PaymentTypeChargeFee"));
+    }
+
+    private static void validateAttempt(OmgPaymentAttempt attempt, Long storeId) {
+        if (attempt == null) {
+            throw business(PAYMENT_QUERY_NOT_AVAILABLE, storeId);
+        }
+        if (isBlank(attempt.getMerchantId()) || isBlank(attempt.getMerchantTradeNo())
+                || attempt.getAmount() == null || attempt.getAmount() <= 0
+                || isBlank(attempt.getHashKeySnapshot()) || isBlank(attempt.getHashIvSnapshot())) {
+            throw business(PAYMENT_QUERY_FAILED, storeId);
+        }
+    }
+
+    private static String normalizeOrderId(String orderId) {
+        if (isBlank(orderId) || orderId.trim().length() > 64) {
+            throw business(ORDER_REQUIRED);
+        }
+        return orderId.trim();
+    }
+
+    private static Date parseDate(String value, boolean required) {
+        if (isBlank(value)) {
+            if (required) {
+                throw new IllegalArgumentException("required OMG date is missing");
+            }
+            return null;
+        }
+        try {
+            return Date.from(LocalDateTime.parse(value, OMG_DATE).atZone(TAIPEI).toInstant());
+        } catch (DateTimeParseException exception) {
+            throw new IllegalArgumentException("invalid OMG date", exception);
+        }
+    }
+
+    private static BigDecimal parseDecimal(String value) {
+        try {
+            return isBlank(value) ? null : new BigDecimal(value);
+        } catch (NumberFormatException exception) {
+            throw new IllegalArgumentException("invalid OMG decimal", exception);
+        }
+    }
+
+    private static String blankToNull(String value) {
+        return isBlank(value) ? null : value;
+    }
+
+    private static String maskTradeNo(String tradeNo) {
+        if (isBlank(tradeNo)) {
+            return "<empty>";
+        }
+        return tradeNo.length() <= 6 ? "***" : "***" + tradeNo.substring(tradeNo.length() - 6);
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.isBlank();
+    }
+
+    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code) {
+        return new OmgPaymentBusinessException(code);
+    }
+
+    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code, Long storeId) {
+        return new OmgPaymentBusinessException(code, storeId);
+    }
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentSettlementResult.java

@@ -0,0 +1,7 @@
+package com.ruoyi.app.omgpay;
+
+/** Local irreversible result after applying trusted OMG gateway facts. */
+public enum OmgPaymentSettlementResult {
+    PAID,
+    FAILED
+}

+ 40 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgQueryResponseParser.java

@@ -0,0 +1,40 @@
+package com.ruoyi.app.omgpay;
+
+import org.springframework.stereotype.Component;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** Strictly decodes OMG's form-like query response without losing signed fields. */
+@Component
+public class OmgQueryResponseParser {
+    static final int MAX_RESPONSE_CHARS = 32 * 1024;
+
+    public Map<String, String> parse(String rawResponse) {
+        if (rawResponse == null || rawResponse.isBlank() || rawResponse.length() > MAX_RESPONSE_CHARS) {
+            throw new IllegalArgumentException("OMG query response is empty or oversized");
+        }
+        LinkedHashMap<String, String> fields = new LinkedHashMap<>();
+        for (String pair : rawResponse.split("&", -1)) {
+            if (pair.isEmpty()) {
+                continue;
+            }
+            int separator = pair.indexOf('=');
+            if (separator < 1) {
+                throw new IllegalArgumentException("OMG query response field is malformed");
+            }
+            String name = URLDecoder.decode(pair.substring(0, separator), StandardCharsets.UTF_8);
+            String value = URLDecoder.decode(pair.substring(separator + 1), StandardCharsets.UTF_8);
+            if (name.isEmpty() || fields.putIfAbsent(name, value) != null) {
+                throw new IllegalArgumentException("OMG query response field is duplicate or empty");
+            }
+        }
+        if (fields.isEmpty()) {
+            throw new IllegalArgumentException("OMG query response has no fields");
+        }
+        return Collections.unmodifiableMap(fields);
+    }
+}

+ 14 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgQueryPaymentRequest.java

@@ -0,0 +1,14 @@
+package com.ruoyi.app.omgpay.dto;
+
+/** User query identifies only the business order; gateway identifiers stay server-owned. */
+public class OmgQueryPaymentRequest {
+    private String orderId;
+
+    public String getOrderId() {
+        return orderId;
+    }
+
+    public void setOrderId(String orderId) {
+        this.orderId = orderId;
+    }
+}

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgQueryPaymentResponse.java

@@ -0,0 +1,15 @@
+package com.ruoyi.app.omgpay.dto;
+
+/** Whitelisted OMG query facts safe to return to the authenticated order owner. */
+public record OmgQueryPaymentResponse(
+        String status,
+        String tradeStatus,
+        String merchantTradeNo,
+        String tradeNo,
+        Integer amount,
+        String paymentDate,
+        String tradeDate,
+        String paymentType,
+        String handlingCharge,
+        String paymentTypeChargeFee) {
+}

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

@@ -238,3 +238,5 @@ omg.pay.credential.unavailable=该门店暂未启用 OMG 支付
 omg.pay.attempt.exists=该订单已有待处理的支付尝试
 omg.pay.configuration.invalid=OMG 支付配置无效
 omg.pay.creation.failed=OMG 支付创建失败,请稍后重试
+omg.pay.query.not.available=当前没有可查询的 OMG 支付信息
+omg.pay.query.failed=无法查询 OMG 支付状态,请稍后重试

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

@@ -241,3 +241,5 @@ omg.pay.credential.unavailable=OMG Pay is not enabled for this store
 omg.pay.attempt.exists=This order already has a pending payment attempt
 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

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

@@ -241,3 +241,5 @@ omg.pay.credential.unavailable=Cửa hàng này chưa bật OMG Pay
 omg.pay.attempt.exists=Đơn hàng này đã có một lần thanh toán đang chờ xử lý
 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

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

@@ -242,3 +242,5 @@ omg.pay.credential.unavailable=该门店暂未启用 OMG 支付
 omg.pay.attempt.exists=该订单已有待处理的支付尝试
 omg.pay.configuration.invalid=OMG 支付配置无效
 omg.pay.creation.failed=OMG 支付创建失败,请稍后重试
+omg.pay.query.not.available=当前没有可查询的 OMG 支付信息
+omg.pay.query.failed=无法查询 OMG 支付状态,请稍后重试

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

@@ -242,3 +242,5 @@ omg.pay.credential.unavailable=此門店尚未啟用 OMG 支付
 omg.pay.attempt.exists=此訂單已有待處理的支付嘗試
 omg.pay.configuration.invalid=OMG 支付設定無效
 omg.pay.creation.failed=OMG 支付建立失敗,請稍後再試
+omg.pay.query.not.available=目前沒有可查詢的 OMG 付款資訊
+omg.pay.query.failed=無法查詢 OMG 付款狀態,請稍後再試

+ 32 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgCheckMacSignerTest.java

@@ -34,6 +34,17 @@ class OmgCheckMacSignerTest {
                 signer.sign(withExtraField(fields, "EmptyExtra", ""), HASH_KEY, HASH_IV));
     }
 
+    @Test
+    void queryResponseFieldsAreSortedCaseInsensitively() {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", "1000031");
+        fields.put("amount", "");
+        fields.put("CustomField1", "");
+        fields.put("card4no", "4242");
+        assertEquals(independentCaseInsensitiveSignature(fields),
+                signer.sign(fields, HASH_KEY, HASH_IV));
+    }
+
     @Test
     void rejectsCallerSuppliedCheckMacValue() {
         assertThrows(IllegalArgumentException.class,
@@ -91,4 +102,25 @@ class OmgCheckMacSignerTest {
         updated.put(key, value);
         return updated;
     }
+
+    private static String independentCaseInsensitiveSignature(Map<String, String> fields) {
+        java.util.TreeMap<String, String> sorted = new java.util.TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        sorted.putAll(fields);
+        String query = sorted.entrySet().stream()
+                .map(entry -> entry.getKey() + "=" + entry.getValue())
+                .collect(java.util.stream.Collectors.joining("&"));
+        String raw = "HashKey=" + HASH_KEY + "&" + query + "&HashIV=" + HASH_IV;
+        String encoded = java.net.URLEncoder.encode(raw, java.nio.charset.StandardCharsets.UTF_8)
+                .replace("%2D", "-").replace("%5F", "_").replace("%2E", ".")
+                .replace("%21", "!").replace("%2A", "*")
+                .replace("%28", "(").replace("%29", ")")
+                .toLowerCase(java.util.Locale.ROOT);
+        try {
+            byte[] digest = java.security.MessageDigest.getInstance("SHA-256")
+                    .digest(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+            return java.util.HexFormat.of().withUpperCase().formatHex(digest);
+        } catch (java.security.NoSuchAlgorithmException exception) {
+            throw new AssertionError(exception);
+        }
+    }
 }

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

@@ -141,6 +141,6 @@ class OmgPaymentControllerTest {
     private static OmgPaymentController controller(OmgPaymentTokenUserResolver resolver,
                                                    OmgPaymentCreateService service) {
         return new OmgPaymentController(resolver, service, mock(OmgPaymentNotifyService.class),
-                mock(OmgIpnAuditService.class));
+                mock(OmgIpnAuditService.class), mock(OmgPaymentQueryService.class));
     }
 }

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

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

+ 34 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java

@@ -9,6 +9,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import java.util.ArrayList;
+import java.math.BigDecimal;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -141,6 +142,39 @@ class OmgPaymentNotifyServiceTest {
         verify(attempts, never()).markPaid(any());
     }
 
+    @Test
+    void verifiedPaidQueryUsesTheSameIrreversibleSettlementAsCallback() {
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order(0L, 0L));
+        when(attempts.selectByMerchantTradeNoForUpdate("OMG123")).thenReturn(attempt);
+        when(attempts.markPaid(any())).thenReturn(1);
+        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
+        OmgPaymentGatewayFacts facts = new OmgPaymentGatewayFacts(
+                OmgPaymentFactSource.QUERY, "1000031", "OMG123", 100, 1,
+                "TradeStatus=1", "GW123", "Credit_CreditCard", null, null,
+                new BigDecimal("0.00"), null);
+
+        assertEquals(OmgPaymentSettlementResult.PAID, service.synchronizeVerifiedQuery(facts));
+
+        verify(attempts).markPaid(any());
+        verify(attempts).supersedeOtherCreated("DD-1", 7L);
+        verify(attempts).markOrderPaid("DD-1");
+    }
+
+    @Test
+    void failedQueryCannotDowngradeAnAlreadyPaidAttempt() {
+        attempt.setAttemptStatus(1);
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order(0L, 1L));
+        when(attempts.selectByMerchantTradeNoForUpdate("OMG123")).thenReturn(attempt);
+        OmgPaymentGatewayFacts facts = new OmgPaymentGatewayFacts(
+                OmgPaymentFactSource.QUERY, "1000031", "OMG123", 100, 10200095,
+                "TradeStatus=10200095", null, null, null, null,
+                new BigDecimal("0.00"), null);
+
+        assertEquals(OmgPaymentSettlementResult.PAID, service.synchronizeVerifiedQuery(facts));
+
+        verify(attempts, never()).markFailed(any());
+    }
+
     private OmgNotifyRequest signedRequest(int rtnCode, int simulatePaid, int amount) {
         return signedRequest(rtnCode, simulatePaid, amount, "1000031");
     }

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

@@ -0,0 +1,76 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgPaymentErrorResponse;
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentRequest;
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse;
+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 static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+class OmgPaymentQueryControllerTest {
+    @Test
+    void exposesAuthenticatedOrderIdOnlyQueryContractAndMapsResult() throws Exception {
+        Method method = OmgPaymentController.class.getDeclaredMethod(
+                "query", String.class, OmgQueryPaymentRequest.class);
+        assertArrayEquals(new String[]{"/query"}, 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);
+        OmgPaymentQueryService queryService = mock(OmgPaymentQueryService.class);
+        when(resolver.requireUserId("token")).thenReturn(5L);
+        OmgQueryPaymentResponse response = new OmgQueryPaymentResponse(
+                "PAID", "1", "OMG123", "GW123", 100,
+                "2026/08/13 12:00:00", "2026/08/13 11:59:00",
+                "Credit_CreditCard", "0", "0.00");
+        when(queryService.query(5L, "DD-1")).thenReturn(response);
+        OmgPaymentController controller = controller(resolver, queryService);
+        OmgQueryPaymentRequest request = new OmgQueryPaymentRequest();
+        request.setOrderId("DD-1");
+
+        AjaxResult result = controller.query("token", request);
+
+        assertSame(response, result.get("data"));
+    }
+
+    @Test
+    void mapsQueryBusinessAndUnexpectedFailuresToStableStatus() {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+            OmgPaymentTokenUserResolver resolver = mock(OmgPaymentTokenUserResolver.class);
+            OmgPaymentQueryService queryService = mock(OmgPaymentQueryService.class);
+            when(resolver.requireUserId("token")).thenReturn(5L);
+            when(queryService.query(5L, "DD-1")).thenThrow(
+                    new OmgPaymentBusinessException(OmgPaymentErrorCode.PAYMENT_QUERY_NOT_AVAILABLE));
+            OmgQueryPaymentRequest request = new OmgQueryPaymentRequest();
+            request.setOrderId("DD-1");
+            OmgPaymentController controller = controller(resolver, queryService);
+
+            AjaxResult business = controller.query("token", request);
+            assertEquals("PAYMENT_QUERY_NOT_AVAILABLE",
+                    ((OmgPaymentErrorResponse) business.get("data")).status());
+
+            when(queryService.query(5L, "DD-1")).thenThrow(new IllegalStateException("gateway down"));
+            AjaxResult unexpected = controller.query("token", request);
+            assertEquals("PAYMENT_QUERY_FAILED",
+                    ((OmgPaymentErrorResponse) unexpected.get("data")).status());
+        }
+    }
+
+    private static OmgPaymentController controller(OmgPaymentTokenUserResolver resolver,
+                                                   OmgPaymentQueryService queryService) {
+        return new OmgPaymentController(resolver, mock(OmgPaymentCreateService.class),
+                mock(OmgPaymentNotifyService.class), mock(OmgIpnAuditService.class), queryService);
+    }
+}

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

@@ -0,0 +1,176 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.*;
+
+class OmgPaymentQueryServiceTest {
+    private static final String HASH_KEY = "5294y06JbISpM5x9";
+    private static final String HASH_IV = "v77hoKGq4kWxNNIS";
+
+    private IOmgPaymentAttemptService attempts;
+    private OmgPaymentQueryGateway gateway;
+    private OmgPaymentNotifyService settlement;
+    private OmgPaymentQueryService service;
+    private OmgPaymentAttempt attempt;
+
+    @BeforeEach
+    void setUp() {
+        attempts = mock(IOmgPaymentAttemptService.class);
+        gateway = mock(OmgPaymentQueryGateway.class);
+        settlement = mock(OmgPaymentNotifyService.class);
+        service = new OmgPaymentQueryService(attempts, gateway, new OmgQueryResponseParser(),
+                new OmgCheckMacSigner(), settlement,
+                Clock.fixed(Instant.ofEpochSecond(1_786_598_400L), ZoneOffset.UTC));
+        attempt = attempt(0);
+        when(attempts.selectOrder("DD-1")).thenReturn(order(0L));
+        when(attempts.selectActiveCreatedByDdId("DD-1")).thenReturn(attempt);
+    }
+
+    @Test
+    void paidGatewayResultCompensatesAQuitePossiblyLostCallback() {
+        when(gateway.query(anyMap())).thenReturn(signedResponse("1", Map.of("FutureEmptyField", "")));
+        when(settlement.synchronizeVerifiedQuery(any())).thenReturn(OmgPaymentSettlementResult.PAID);
+
+        OmgQueryPaymentResponse response = service.query(5L, "DD-1");
+
+        assertEquals("PAID", response.status());
+        assertEquals("1", response.tradeStatus());
+        verify(settlement).synchronizeVerifiedQuery(argThat(facts ->
+                facts.source() == OmgPaymentFactSource.QUERY
+                        && facts.merchantTradeNo().equals("OMG123")
+                        && facts.resultCode() == 1));
+        @SuppressWarnings("unchecked")
+        var request = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(gateway).query(request.capture());
+        assertEquals("1000031", request.getValue().get("MerchantID"));
+        assertEquals("OMG123", request.getValue().get("MerchantTradeNo"));
+        assertEquals("1786598400", request.getValue().get("TimeStamp"));
+        assertEquals(4, request.getValue().size());
+    }
+
+    @Test
+    void unpaidResultIsReadOnlyAndExplicitFailureIsSynchronized() {
+        when(gateway.query(anyMap())).thenReturn(signedResponse("0", Map.of()));
+        assertEquals("UNPAID", service.query(5L, "DD-1").status());
+        verify(settlement, never()).synchronizeVerifiedQuery(any());
+
+        reset(gateway, settlement);
+        when(gateway.query(anyMap())).thenReturn(signedResponse("10200095", Map.of()));
+        when(settlement.synchronizeVerifiedQuery(any())).thenReturn(OmgPaymentSettlementResult.FAILED);
+        assertEquals("FAILED", service.query(5L, "DD-1").status());
+        verify(settlement).synchronizeVerifiedQuery(argThat(facts -> facts.resultCode() == 10200095));
+    }
+
+    @Test
+    void paidOrderQueriesItsPaidAttemptInsteadOfAnOldOrClientSelectedAttempt() {
+        OmgPaymentAttempt paid = attempt(1);
+        when(attempts.selectOrder("DD-1")).thenReturn(order(1L));
+        when(attempts.selectPaidByDdId("DD-1")).thenReturn(paid);
+        when(gateway.query(anyMap())).thenReturn(signedResponse("1", Map.of()));
+        when(settlement.synchronizeVerifiedQuery(any())).thenReturn(OmgPaymentSettlementResult.PAID);
+
+        assertEquals("PAID", service.query(5L, "DD-1").status());
+
+        verify(attempts).selectPaidByDdId("DD-1");
+        verify(attempts, never()).selectActiveCreatedByDdId(anyString());
+    }
+
+    @Test
+    void paidAttemptIsNeverReportedAsUnpaidByALaterQuery() {
+        OmgPaymentAttempt paid = attempt(1);
+        when(attempts.selectOrder("DD-1")).thenReturn(order(1L));
+        when(attempts.selectPaidByDdId("DD-1")).thenReturn(paid);
+        when(gateway.query(anyMap())).thenReturn(signedResponse("0", Map.of()));
+
+        assertEquals("PAID", service.query(5L, "DD-1").status());
+
+        verify(settlement, never()).synchronizeVerifiedQuery(any());
+    }
+
+    @Test
+    void rejectsUnauthorizedMissingAttemptAndAnyTamperedExtraField() {
+        assertThrows(OmgPaymentBusinessException.class, () -> service.query(6L, "DD-1"));
+        when(attempts.selectActiveCreatedByDdId("DD-1")).thenReturn(null);
+        OmgPaymentBusinessException missing = assertThrows(OmgPaymentBusinessException.class,
+                () -> service.query(5L, "DD-1"));
+        assertEquals(OmgPaymentErrorCode.PAYMENT_QUERY_NOT_AVAILABLE, missing.getCode());
+
+        when(attempts.selectActiveCreatedByDdId("DD-1")).thenReturn(attempt);
+        String signed = signedResponse("1", Map.of("FutureEmptyField", ""));
+        when(gateway.query(anyMap())).thenReturn(signed.replace("FutureEmptyField=", "FutureEmptyField=tampered"));
+        OmgPaymentBusinessException tampered = assertThrows(OmgPaymentBusinessException.class,
+                () -> service.query(5L, "DD-1"));
+        assertEquals(OmgPaymentErrorCode.PAYMENT_QUERY_FAILED, tampered.getCode());
+        verify(settlement, never()).synchronizeVerifiedQuery(any());
+    }
+
+    private String signedResponse(String tradeStatus, Map<String, String> extras) {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", "1000031");
+        fields.put("MerchantTradeNo", "OMG123");
+        fields.put("StoreID", "");
+        fields.put("TradeNo", "1".equals(tradeStatus) ? "GW123" : "");
+        fields.put("TradeAmt", "100");
+        fields.put("PaymentDate", "1".equals(tradeStatus) ? "2026/08/13 12:00:00" : "");
+        fields.put("PaymentType", "1".equals(tradeStatus) ? "Credit_CreditCard" : "");
+        fields.put("HandlingCharge", "0");
+        fields.put("PaymentTypeChargeFee", "0.00");
+        fields.put("TradeDate", "2026/08/13 11:59:00");
+        fields.put("TradeStatus", tradeStatus);
+        fields.put("ItemName", "Order DD-1");
+        fields.put("CustomField1", "");
+        fields.put("CustomField2", "");
+        fields.put("CustomField3", "");
+        fields.put("CustomField4", "");
+        fields.putAll(extras);
+        fields.put("CheckMacValue", new OmgCheckMacSigner().sign(fields, HASH_KEY, HASH_IV));
+        return fields.entrySet().stream()
+                .map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue()))
+                .collect(java.util.stream.Collectors.joining("&"));
+    }
+
+    private static String encode(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+
+    private static OmgPaymentAttempt attempt(int status) {
+        OmgPaymentAttempt result = new OmgPaymentAttempt();
+        result.setId(7L);
+        result.setDdId("DD-1");
+        result.setMerchantTradeNo("OMG123");
+        result.setMerchantId("1000031");
+        result.setAmount(100);
+        result.setHashKeySnapshot(HASH_KEY);
+        result.setHashIvSnapshot(HASH_IV);
+        result.setAttemptStatus(status);
+        return result;
+    }
+
+    private static OmgPaymentOrderSnapshot order(long payStatus) {
+        OmgPaymentOrderSnapshot result = new OmgPaymentOrderSnapshot();
+        result.setDdId("DD-1");
+        result.setParentDdId("DD-1");
+        result.setStoreId(77L);
+        result.setUserId(5L);
+        result.setPayStatus(payStatus);
+        result.setPayType("2");
+        return result;
+    }
+}

+ 34 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgQueryResponseParserTest.java

@@ -0,0 +1,34 @@
+package com.ruoyi.app.omgpay;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class OmgQueryResponseParserTest {
+    private final OmgQueryResponseParser parser = new OmgQueryResponseParser();
+
+    @Test
+    void preservesEveryActualFieldIncludingUnknownAndEmptyValues() {
+        Map<String, String> fields = parser.parse(
+                "MerchantTradeNo=OMG123&PaymentDate=&FutureField=A%26B&");
+
+        assertEquals("OMG123", fields.get("MerchantTradeNo"));
+        assertEquals("", fields.get("PaymentDate"));
+        assertEquals("A&B", fields.get("FutureField"));
+    }
+
+    @Test
+    void rejectsDuplicateMalformedAndOversizedResponses() {
+        assertThrows(IllegalArgumentException.class,
+                () -> parser.parse("TradeStatus=1&TradeStatus=0"));
+        assertThrows(IllegalArgumentException.class,
+                () -> parser.parse("TradeStatus=1&broken"));
+        assertThrows(IllegalArgumentException.class,
+                () -> parser.parse("TradeStatus=%ZZ"));
+        assertThrows(IllegalArgumentException.class,
+                () -> parser.parse("a=" + "x".repeat(OmgQueryResponseParser.MAX_RESPONSE_CHARS)));
+    }
+}

+ 2 - 1
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentAttempt.java

@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 
 import java.util.Date;
+import java.math.BigDecimal;
 
 /**
  * Local append-only record of an OMG checkout form created for an order.
@@ -33,7 +34,7 @@ public class OmgPaymentAttempt {
     private String paymentType;
     private Date paymentDate;
     private Date tradeDate;
-    private Integer paymentTypeChargeFee;
+    private BigDecimal paymentTypeChargeFee;
     private Integer simulatePaid;
     private Date lastNotifyTime;
     @TableField(fill = FieldFill.INSERT)

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

@@ -10,9 +10,18 @@ import org.apache.ibatis.annotations.Param;
 public interface OmgPaymentAttemptMapper {
     OmgPaymentOrderSnapshot selectOrderForUpdate(@Param("ddId") String ddId);
 
+    /** Read-only ownership snapshot used before calling the OMG query API. */
+    OmgPaymentOrderSnapshot selectOrder(@Param("ddId") String ddId);
+
     /** Current read used both before creation and after an active-order unique-key conflict. */
     OmgPaymentAttempt selectActiveCreatedByDdIdForUpdate(@Param("ddId") String ddId);
 
+    /** The single current unpaid attempt eligible for a user-triggered query. */
+    OmgPaymentAttempt selectActiveCreatedByDdId(@Param("ddId") String ddId);
+
+    /** The first paid fact for an already-paid order; payment facts are irreversible. */
+    OmgPaymentAttempt selectPaidByDdId(@Param("ddId") String ddId);
+
     /** Current read used after an insert conflict to classify a committed trade-number collision. */
     OmgPaymentAttempt selectByMerchantTradeNoForUpdate(@Param("merchantTradeNo") String merchantTradeNo);
 

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

@@ -9,9 +9,15 @@ import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 public interface IOmgPaymentAttemptService {
     OmgPaymentOrderSnapshot selectOrderForUpdate(String ddId);
 
+    OmgPaymentOrderSnapshot selectOrder(String ddId);
+
     /** Current read that makes committed active attempts visible during duplicate classification. */
     OmgPaymentAttempt selectActiveCreatedByDdIdForUpdate(String ddId);
 
+    OmgPaymentAttempt selectActiveCreatedByDdId(String ddId);
+
+    OmgPaymentAttempt selectPaidByDdId(String ddId);
+
     /** Current read that classifies a committed MerchantTradeNo unique-key collision. */
     OmgPaymentAttempt selectByMerchantTradeNoForUpdate(String merchantTradeNo);
 

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

@@ -30,11 +30,26 @@ public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
         return StrUtil.isBlank(ddId) ? null : mapper.selectOrderForUpdate(ddId.trim());
     }
 
+    @Override
+    public OmgPaymentOrderSnapshot selectOrder(String ddId) {
+        return StrUtil.isBlank(ddId) ? null : mapper.selectOrder(ddId.trim());
+    }
+
     @Override
     public OmgPaymentAttempt selectActiveCreatedByDdIdForUpdate(String ddId) {
         return StrUtil.isBlank(ddId) ? null : mapper.selectActiveCreatedByDdIdForUpdate(ddId.trim());
     }
 
+    @Override
+    public OmgPaymentAttempt selectActiveCreatedByDdId(String ddId) {
+        return StrUtil.isBlank(ddId) ? null : mapper.selectActiveCreatedByDdId(ddId.trim());
+    }
+
+    @Override
+    public OmgPaymentAttempt selectPaidByDdId(String ddId) {
+        return StrUtil.isBlank(ddId) ? null : mapper.selectPaidByDdId(ddId.trim());
+    }
+
     @Override
     public OmgPaymentAttempt selectByMerchantTradeNoForUpdate(String merchantTradeNo) {
         return StrUtil.isBlank(merchantTradeNo)

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

@@ -22,6 +22,13 @@
         FROM pos_order WHERE dd_id = #{ddId} LIMIT 1 FOR UPDATE
     </select>
 
+    <select id="selectOrder"
+            resultType="com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot">
+        SELECT id, dd_id AS ddId, parent_dd_id AS parentDdId, md_id AS storeId,
+               user_id AS userId, amount, state, pay_status AS payStatus, pay_type AS payType
+        FROM pos_order WHERE dd_id = #{ddId} LIMIT 1
+    </select>
+
     <!-- Locking current reads see attempts committed by a transaction that won a unique-key race. -->
     <select id="selectActiveCreatedByDdIdForUpdate"
             resultType="com.ruoyi.system.omgpay.domain.OmgPaymentAttempt">
@@ -31,6 +38,23 @@
         LIMIT 1 FOR UPDATE
     </select>
 
+    <select id="selectActiveCreatedByDdId"
+            resultType="com.ruoyi.system.omgpay.domain.OmgPaymentAttempt">
+        SELECT <include refid="attemptColumns"/>
+        FROM pos_order_omg_attempt
+        WHERE active_dd_id = #{ddId}
+        LIMIT 1
+    </select>
+
+    <select id="selectPaidByDdId"
+            resultType="com.ruoyi.system.omgpay.domain.OmgPaymentAttempt">
+        SELECT <include refid="attemptColumns"/>
+        FROM pos_order_omg_attempt
+        WHERE dd_id = #{ddId} AND attempt_status = 1
+        ORDER BY payment_date IS NULL, payment_date ASC, id ASC
+        LIMIT 1
+    </select>
+
     <select id="selectByMerchantTradeNoForUpdate"
             resultType="com.ruoyi.system.omgpay.domain.OmgPaymentAttempt">
         SELECT <include refid="attemptColumns"/>

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

@@ -135,3 +135,22 @@ This response is used when the request cannot be trusted or atomically processed
 ### IPN logging
 
 Every HTTP request is inserted before business handling into existing `ipn_log` with `type=omg`, request IP, receive time and the full raw form body. This insert uses an independent transaction. Failure to write `ipn_log` is logged but does not block payment processing.
+
+## POST `/pay/omg/query`
+
+Header `token` is required. The JSON body contains only the business order ID:
+
+```json
+{"orderId":"991786433092835"}
+```
+
+The server verifies ownership and chooses the only current payment attempt. An unpaid order queries its unique `CREATED` attempt; an already-paid order queries its first `PAID` attempt. The client cannot provide `MerchantTradeNo`, amount, credentials or gateway URL.
+
+- POST `MerchantID`, `MerchantTradeNo`, current Unix `TimeStamp` and `CheckMacValue` to the OMG stage `QueryTradeInfo/V5` endpoint.
+- Use the attempt credential snapshot, parse and sign every actual response field (including additional and empty fields), excluding only `CheckMacValue`.
+- Verify `MerchantID`, `MerchantTradeNo` and `TradeAmt` against the attempt snapshot.
+- `TradeStatus=1` reuses the callback's locked idempotent settlement transaction and compensates a lost callback by marking the attempt/order paid.
+- `TradeStatus=0` changes no local state; `10200095` synchronizes an unpaid attempt to failed; paid facts are irreversible.
+- User-triggered query is not an incoming IPN and is not inserted into `ipn_log`.
+
+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`.

+ 1 - 1
specs/020-omg-payment-rebuild/data-model.md

@@ -98,7 +98,7 @@ public class OmgPaymentAttempt {
     private String paymentType;
     private Date paymentDate;
     private Date tradeDate;
-    private Integer paymentTypeChargeFee;
+    private BigDecimal paymentTypeChargeFee;
     private Integer simulatePaid;
     private Date lastNotifyTime;
     private Date createTime;

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

@@ -4,14 +4,14 @@
 
 **Goal:** 从零实现测试环境 `POST /pay/omg/create` 和 `POST /pay/omg/notify`,按门店独立凭证创建官方 AIO 表单,并以凭证快照可信、幂等地同步最终付款结果。
 
-**Architecture:** `ruoyi-system/com.ruoyi.system.omgpay` 负责新尝试表、订单锁、状态 CAS、凭证快照和 `ipn_log` 独立事务;`ruoyi-admin/com.ruoyi.app.omgpay` 负责 token 创建入口、原始表单 DTO 边界、官方检查码、回调校验、事务编排与日志。创建流程保存实际密钥快照;回调先独立写 IPN,再按 `MerchantTradeNo` 锁尝试并用快照验签,成功只更新订单 `pay_status`。旧 `pos_store_omg` 凭证查询保持不变,其余旧 OMG 支付代码不得被新流程引用。
+**Architecture:** `ruoyi-system/com.ruoyi.system.omgpay` 负责新尝试表、订单锁、状态 CAS、凭证快照和 `ipn_log` 独立事务;`ruoyi-admin/com.ruoyi.app.omgpay` 负责 token 创建/查询入口、外部表单边界、官方检查码、回调/查询校验、事务编排与日志。创建流程保存实际密钥快照;回调和可信查询结果共同进入同一不可逆支付状态机,查询成功可补偿丢失回调并只更新订单 `pay_status`。旧 `pos_store_omg` 凭证查询保持不变,其余旧 OMG 支付代码不得被新流程引用。
 
 **Tech Stack:** Java 21、Spring Boot 3、MyBatis/MyBatis-Plus、MySQL、JUnit 5、Mockito、SLF4J/Logback、Maven Surefire。
 
 ## Global Constraints
 
 - OMG AIO 官方技术文件 V1.5.3 是支付协议唯一外部事实来源;不得从旧代码或 `specs/016-omg-payment` 复制行为。
-- 只实现创建支付订单与最终付款结果回调;取号通知、查询、补单、退款、推送、关账和正式环境全部不实现。
+- 只实现创建、最终付款结果回调及用户触发的当前支付查询补偿;取号通知、定时/批量补单、退款、推送、关账和正式环境全部不实现。
 - 新代码只能位于 `com.ruoyi.app.omgpay`、`com.ruoyi.system.omgpay` 及对应资源/测试目录。
 - `ruoyi-admin -> ruoyi-system`;`ruoyi-system` 禁止导入 `com.ruoyi.app.*`。
 - 每个门店独立使用 `pos_store_omg` 中已启用的 `MerchantID / HashKey / HashIV`;不修改可信凭证存储与管理代码。
@@ -1033,6 +1033,9 @@ Do not create an empty commit.
 
 ## Verification Gates
 
+- 查询测试源码覆盖当前尝试选择、请求签名、额外/空字段响应验签、未付款只读、失败同步、成功补偿和已付款不可逆。
+- 本阶段遵守用户要求不运行 Maven、编译或测试,待所有 OMG 功能调整完成后统一验证。
+
 1. Official signature vector equals the published SHA-256 CheckMacValue.
 2. All 15 actual pre-sign fields, including extra/expiry fields, are signer inputs; empty fields are retained generically.
 3. Success form has exactly 16 fields and the exact stage URL.
@@ -1089,3 +1092,12 @@ Self-review result: all 41 functional requirements have an implementation task a
 ## Complexity Tracking
 
 No constitution violations. The separate form factory, signer, generator and persistence service each hold one security-sensitive responsibility and are directly unit testable; none is a generic multi-provider abstraction.
+
+### Query compensation transaction
+
+1. Controller 只接收 token 与 `orderId`,验证订单归属。
+2. 未付款订单选择唯一 `CREATED`;已付款订单选择首条 `PAID`,客户端不能指定交易号。
+3. 使用尝试的凭证快照和当前 Unix 秒签署 stage `QueryTradeInfo/V5` 请求。
+4. 严格解析响应,全部实际字段参与验签,并核对 MerchantID、MerchantTradeNo 与金额。
+5. `TradeStatus=1/10200095` 进入与回调相同的订单/尝试锁和不可逆状态机;`0` 保持只读。
+6. 查询不是 IPN,不写 `ipn_log`;日志只记录必要的脱敏定位信息。

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

@@ -18,6 +18,7 @@
 - 由客户端在当前页面以表单 POST 进入 OMG 测试收银台。
 - 防止同一业务订单同时产生多个未结束 OMG 支付尝试。
 - 在 `POST /pay/omg/notify` 接收 OMG 最终付款结果,按创建尝试的凭证快照验签并幂等更新支付事实。
+- 在 `POST /pay/omg/query` 只查询订单当前有效支付尝试;可信已付款结果复用回调状态机补偿丢失回调。
 - 每次回调独立写入现有 `ipn_log`,并保存完整、可重放的 form-urlencoded 回传内容。
 - 成功回调只把订单 `payStatus` 更新为已付款,不推进订单、配送或推送流程。
 - 停用旧 OMG Controller 及其补单、退款、定时任务和订单取消调用入口。
@@ -26,7 +27,7 @@
 
 - ATM、CVS、BarcodeATM 取号结果通知及缴费信息展示。
 - `OrderResultURL`、`PaymentInfoURL`、`ClientRedirectURL`、`ClientBackURL`。
-- OMG 订单查询、自动补单、人工补单
+- 定时/批量自动补单、独立人工补单入口;用户触发的当前支付查询补偿属于本阶段范围
 - 退款、取消交易、信用卡关账。
 - 分期、定期定额、记忆卡号、银联专用流程。
 - 正式环境开放。
@@ -128,6 +129,10 @@ OMG 向 `ReturnURL` 发送最终付款结果时,系统保存本次 HTTP 回传
 
 ### Edge Cases
 
+- 支付成功回调丢失时,用户查询当前有效尝试;完整验签及商户号、交易号、金额核对后必须原子补偿为已付款。
+- 查询返回未付款时不得修改本地状态;返回 `10200095` 时同步失败信息;任何查询不得把已付款降级。
+- 查询响应新增字段或空值字段仍必须参与检查码计算,客户端不得指定要查询的 `MerchantTradeNo`。
+
 - 业务订单号含有不适合 OMG `MerchantTradeNo` 的字符时,系统使用独立生成的英数字编号,不直接拼接或截断业务订单号。
 - 随机生成的 `MerchantTradeNo` 发生唯一索引冲突时,系统可在同一创建事务中重新生成;达到受控次数仍失败时整笔创建回滚。
 - 客户端取得表单但未提交、网络中断或关闭页面时,本地只能保持 `CREATED`,不得宣称 OMG 已建立或未付款。
@@ -143,6 +148,12 @@ OMG 向 `ReturnURL` 发送最终付款结果时,系统保存本次 HTTP 回传
 
 ### Functional Requirements
 
+- **FR-037**: 查询接口 MUST 仅接受业务订单号并验证 token 用户归属;服务端 MUST 选择唯一当前 `CREATED` 尝试,已付款订单选择其首条 `PAID` 尝试。
+- **FR-038**: 查询 MUST 使用尝试保存的 `MerchantID / HashKey / HashIV` 快照向 OMG stage `QueryTradeInfo/V5` 发送表单请求。
+- **FR-039**: 查询响应 MUST 将全部实际回传参数(含额外与空值)纳入检查码计算,仅排除 `CheckMacValue`,并核对商户号、交易号与金额。
+- **FR-040**: `TradeStatus=1` MUST 复用回调的锁与幂等状态机更新尝试和订单为已付款;`0` MUST 不修改;`10200095` MUST 同步失败;已付款 MUST 不可逆。
+- **FR-041**: 查询只返回白名单字段且不写 `ipn_log`;HashKey、HashIV、CheckMacValue 和完整网关响应 MUST NOT 返回客户端或写入应用日志。
+
 - **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 支付流水表。
 - **FR-003**: 系统 MAY 复用现有 `pos_store_omg` 门店凭证查询实现,且这是唯一允许复用的旧 OMG 实现;新创建流程 MUST 按订单门店读取该门店已启用的 `MerchantID / HashKey / HashIV`。

+ 11 - 1
specs/020-omg-payment-rebuild/tasks.md

@@ -4,7 +4,7 @@
 
 **Tests**: 本功能强制 TDD。每个生产任务先写失败测试并实际观察失败,再写最小实现。
 
-**Scope**: 完成测试环境创建支付订单和最终付款结果回调;不实现取号、查询、补单、退款、推送、关账或正式环境。
+**Scope**: 完成测试环境创建支付订单、最终付款结果回调及用户触发的当前支付查询补偿;不实现取号、定时/批量补单、退款、推送、关账或正式环境。
 
 ## Phase 1: 新持久化基础
 
@@ -109,6 +109,16 @@
 - [x] T062 运行 `git diff --check`、定向 `rg` 和 staged diff 审计;本阶段不运行 Maven、编译或测试
 - [ ] T063 在后续 OMG 查询、补单、退款等功能全部调整完成后,统一运行 JDK 21 定向测试、模块构建和完整回归
 
+## Phase 11: 当前有效支付查询与丢失回调补偿
+
+- [x] T064 [US5] 扩展 Mapper/Service,以订单归属读取唯一 `CREATED` 或已付款订单的首条 `PAID` 尝试,不接受客户端指定 `MerchantTradeNo`
+- [x] T065 [US5] 编写查询响应严格解析、全部实际字段验签、额外/空值字段篡改、当前尝试选择与 Controller 契约测试源码
+- [x] T066 [US5] 新建 `OmgPaymentQueryGateway`,只 POST OMG stage `QueryTradeInfo/V5`,使用尝试的 MerchantID/HashKey/HashIV 快照签名并设置连接/读取超时
+- [x] T067 [US5] 实现 `POST /pay/omg/query`:核验订单归属、交易号、商户号、金额和完整响应签名,只返回字段白名单
+- [x] T068 [US5] 将可信 `TradeStatus=1` 复用回调同一幂等事务补偿为已付款;`0` 不修改;`10200095` 同步失败;任何结果都不得降级已付款事实
+- [x] T069 [US5] 增加查询 i18n、必要安全日志、API/规格说明;不写 `ipn_log`,因为它不是网关主动 IPN
+- [ ] T070 所有 OMG 功能调整完成后,统一执行本阶段测试源码、JDK 21 模块构建和完整回归
+
 ## Dependencies & Execution Order
 
 - Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 → Phase 8 → Phase 9 → Phase 10。