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

feat: rebuild OMG payment notification

qmj 2 недель назад
Родитель
Сommit
69c462040f
35 измененных файлов с 1225 добавлено и 50 удалено
  1. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyForm.java
  2. 60 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyFormParser.java
  3. 84 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyRawBodyFilter.java
  4. 48 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyRequestArgumentResolver.java
  5. 22 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyWebMvcConfiguration.java
  6. 46 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java
  7. 2 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java
  8. 249 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  9. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgNotifyField.java
  10. 64 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgNotifyRequest.java
  11. 1 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  12. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  13. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  14. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  15. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  16. 4 4
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgLegacyRetirementTest.java
  17. 50 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgNotifyFormParserTest.java
  18. 28 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgNotifyRawBodyFilterTest.java
  19. 11 4
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java
  20. 14 7
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java
  21. 81 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyControllerTest.java
  22. 180 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java
  23. 11 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentAttempt.java
  24. 14 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java
  25. 14 1
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java
  26. 31 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/OmgIpnAuditService.java
  27. 42 3
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java
  28. 51 2
      ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml
  29. 8 0
      ruoyi-system/src/test/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapperContractTest.java
  30. 42 0
      ruoyi-system/src/test/java/com/ruoyi/system/omgpay/service/OmgIpnAuditServiceTest.java
  31. 16 8
      ruoyi-system/src/test/java/com/ruoyi/system/omgpay/service/OmgPaymentAttemptServiceTest.java
  32. 2 2
      specs/020-omg-payment-rebuild/quickstart.md
  33. 1 1
      specs/020-omg-payment-rebuild/spec.md
  34. 15 15
      specs/020-omg-payment-rebuild/tasks.md
  35. 13 1
      updatesql/sql.md

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyForm.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.omgpay;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Marks the one DTO populated from the complete raw OMG callback form. */
+@Target(ElementType.PARAMETER)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface OmgNotifyForm {
+}

+ 60 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyFormParser.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgNotifyField;
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import org.springframework.stereotype.Component;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Strict parser that preserves the original callback body and every actual field. */
+@Component
+public class OmgNotifyFormParser {
+    static final int MAX_FORM_BYTES = 32 * 1024;
+    private static final String FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
+
+    public OmgNotifyRequest parse(byte[] body, String sourceIp, String contentType) {
+        byte[] safeBody = body == null ? new byte[0] : body;
+        String raw = new String(safeBody, StandardCharsets.UTF_8);
+        if (contentType == null || !contentType.toLowerCase().startsWith(FORM_CONTENT_TYPE)) {
+            return invalid(sourceIp, raw, "UNSUPPORTED_CONTENT_TYPE");
+        }
+        if (safeBody.length == 0) {
+            return invalid(sourceIp, raw, "EMPTY_BODY");
+        }
+        if (safeBody.length > MAX_FORM_BYTES) {
+            return invalid(sourceIp, raw, "BODY_TOO_LARGE");
+        }
+        try {
+            return new OmgNotifyRequest(sourceIp, raw, decodeFields(raw), null);
+        } catch (IllegalArgumentException exception) {
+            return invalid(sourceIp, raw, "MALFORMED_FORM");
+        }
+    }
+
+    private static List<OmgNotifyField> decodeFields(String raw) {
+        List<OmgNotifyField> fields = new ArrayList<>();
+        Set<String> names = new HashSet<>();
+        for (String pair : raw.split("&", -1)) {
+            int separator = pair.indexOf('=');
+            if (separator < 1) {
+                throw new IllegalArgumentException("field name/value separator missing");
+            }
+            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() || !names.add(name)) {
+                throw new IllegalArgumentException("duplicate or empty field name");
+            }
+            fields.add(new OmgNotifyField(name, value));
+        }
+        return fields;
+    }
+
+    static OmgNotifyRequest invalid(String sourceIp, String raw, String reason) {
+        return new OmgNotifyRequest(sourceIp, raw, List.of(), reason);
+    }
+}

+ 84 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyRawBodyFilter.java

@@ -0,0 +1,84 @@
+package com.ruoyi.app.omgpay;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+/** Preserves the callback bytes before any interceptor asks the servlet container for form parameters. */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+public class OmgNotifyRawBodyFilter extends OncePerRequestFilter {
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        String contextPath = request.getContextPath() == null ? "" : request.getContextPath();
+        String path = request.getRequestURI().substring(contextPath.length());
+        return !"POST".equalsIgnoreCase(request.getMethod()) || !"/pay/omg/notify".equals(path);
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+        byte[] body = request.getInputStream().readNBytes(OmgNotifyFormParser.MAX_FORM_BYTES + 1);
+        filterChain.doFilter(new CachedBodyRequest(request, body), response);
+    }
+
+    private static final class CachedBodyRequest extends HttpServletRequestWrapper {
+        private final byte[] body;
+
+        private CachedBodyRequest(HttpServletRequest request, byte[] body) {
+            super(request);
+            this.body = body;
+        }
+
+        @Override
+        public ServletInputStream getInputStream() {
+            ByteArrayInputStream input = new ByteArrayInputStream(body);
+            return new ServletInputStream() {
+                @Override
+                public boolean isFinished() {
+                    return input.available() == 0;
+                }
+
+                @Override
+                public boolean isReady() {
+                    return true;
+                }
+
+                @Override
+                public void setReadListener(ReadListener readListener) {
+                    // Requests are consumed synchronously by the MVC argument resolver.
+                }
+
+                @Override
+                public int read() {
+                    return input.read();
+                }
+            };
+        }
+
+        @Override
+        public BufferedReader getReader() {
+            return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
+        }
+
+        @Override
+        public String getCharacterEncoding() {
+            return StandardCharsets.UTF_8.name();
+        }
+    }
+}

+ 48 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyRequestArgumentResolver.java

@@ -0,0 +1,48 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.common.utils.ip.IpUtils;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.core.MethodParameter;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.support.WebDataBinderFactory;
+import org.springframework.web.context.request.NativeWebRequest;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.method.support.ModelAndViewContainer;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/** Reads the raw form before MVC can discard unknown or empty callback fields. */
+@Component
+public class OmgNotifyRequestArgumentResolver implements HandlerMethodArgumentResolver {
+    private final OmgNotifyFormParser parser;
+
+    public OmgNotifyRequestArgumentResolver(OmgNotifyFormParser parser) {
+        this.parser = parser;
+    }
+
+    @Override
+    public boolean supportsParameter(MethodParameter parameter) {
+        return OmgNotifyRequest.class.equals(parameter.getParameterType())
+                && parameter.hasParameterAnnotation(OmgNotifyForm.class);
+    }
+
+    @Override
+    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
+                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
+        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
+        if (request == null) {
+            return OmgNotifyFormParser.invalid("unknown", "", "REQUEST_UNAVAILABLE");
+        }
+        String sourceIp = IpUtils.getIpAddr(request);
+        try {
+            byte[] body = request.getInputStream().readNBytes(OmgNotifyFormParser.MAX_FORM_BYTES + 1);
+            return parser.parse(body, sourceIp, request.getContentType());
+        } catch (IOException exception) {
+            return OmgNotifyFormParser.invalid(sourceIp,
+                    "<request-body-read-error:" + exception.getClass().getSimpleName() + ">",
+                    "BODY_READ_FAILED");
+        }
+    }
+}

+ 22 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyWebMvcConfiguration.java

@@ -0,0 +1,22 @@
+package com.ruoyi.app.omgpay;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.method.support.HandlerMethodArgumentResolver;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import java.util.List;
+
+/** Registers the dedicated OMG callback DTO boundary. */
+@Configuration
+public class OmgNotifyWebMvcConfiguration implements WebMvcConfigurer {
+    private final OmgNotifyRequestArgumentResolver resolver;
+
+    public OmgNotifyWebMvcConfiguration(OmgNotifyRequestArgumentResolver resolver) {
+        this.resolver = resolver;
+    }
+
+    @Override
+    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
+        resolvers.add(resolver);
+    }
+}

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

@@ -2,10 +2,12 @@ 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.common.annotation.Anonymous;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.omgpay.service.OmgIpnAuditService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -13,6 +15,10 @@ import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestHeader;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+import java.nio.charset.StandardCharsets;
 
 @RestController
 @RequestMapping("/pay/omg")
@@ -21,11 +27,50 @@ public class OmgPaymentController {
 
     private final OmgPaymentTokenUserResolver tokenUserResolver;
     private final OmgPaymentCreateService createService;
+    private final OmgPaymentNotifyService notifyService;
+    private final OmgIpnAuditService ipnAuditService;
 
     public OmgPaymentController(OmgPaymentTokenUserResolver tokenUserResolver,
-                                OmgPaymentCreateService createService) {
+                                OmgPaymentCreateService createService,
+                                OmgPaymentNotifyService notifyService,
+                                OmgIpnAuditService ipnAuditService) {
         this.tokenUserResolver = tokenUserResolver;
         this.createService = createService;
+        this.notifyService = notifyService;
+        this.ipnAuditService = ipnAuditService;
+    }
+
+    /** OMG server-to-server final payment notification. */
+    @Anonymous
+    @PostMapping(value = "/notify", produces = MediaType.TEXT_PLAIN_VALUE)
+    public ResponseEntity<String> notify(@OmgNotifyForm OmgNotifyRequest request) {
+        String sourceIp = request == null ? "unknown" : request.getSourceIp();
+        String rawForm = request == null ? "" : request.getRawForm();
+        // Complete callback content is intentionally logged so an operator can copy and replay it.
+        log.info("OMG payment notify received ip={}, rawForm={}", sourceIp, rawForm);
+        try {
+            ipnAuditService.append(sourceIp, rawForm);
+        } catch (Exception auditError) {
+            // Audit storage is best effort and must never cause loss of an otherwise valid payment fact.
+            log.error("OMG payment notify IPN append failed ip={}, rawForm={}",
+                    sourceIp, rawForm, auditError);
+        }
+        try {
+            if (notifyService.process(request)) {
+                return text("1|OK");
+            }
+            log.warn("OMG payment notify rejected ip={}, reason={}", sourceIp,
+                    request == null ? "REQUEST_MISSING" : request.getInvalidReason());
+        } catch (Exception error) {
+            log.error("OMG payment notify transaction failed ip={}, rawForm={}", sourceIp, rawForm, error);
+        }
+        return text("0|ERROR");
+    }
+
+    private static ResponseEntity<String> text(String body) {
+        return ResponseEntity.ok()
+                .contentType(new MediaType("text", "plain", StandardCharsets.UTF_8))
+                .body(body);
     }
 
     @Anonymous

+ 2 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java

@@ -65,7 +65,8 @@ public class OmgPaymentCreateService {
             }
             try {
                 OmgPaymentAttempt attempt = attempts.createCreated(order.getDdId(), merchantTradeNo,
-                        order.getStoreId(), credential.getMerchantId(), order.getAmount());
+                        order.getStoreId(), credential.getMerchantId(), order.getAmount(),
+                        credential.getHashKey(), credential.getHashIv());
                 OmgCreatePaymentResponse response = new OmgCreatePaymentResponse(form.gatewayUrl(), form.fields());
                 return new OmgPaymentCreateOutcome(response, attempt.getId(), order.getDdId(), userId,
                         order.getStoreId(), order.getAmount(), maskMerchantTradeNo(merchantTradeNo));

+ 249 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -0,0 +1,249 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+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.transaction.annotation.Transactional;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+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.List;
+
+/** Verifies and applies one final OMG payment notification transactionally. */
+@Service
+public class OmgPaymentNotifyService {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentNotifyService.class);
+    private static final int STATUS_PAID = 1;
+    private static final int STATUS_SUPERSEDED = 3;
+    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", "RtnCode", "RtnMsg", "TradeNo", "TradeAmt",
+            "PaymentDate", "PaymentType", "PaymentTypeChargeFee", "TradeDate", "SimulatePaid",
+            "CustomField1", "CustomField2", "CustomField3", "CustomField4", "CheckMacValue");
+
+    private final IOmgPaymentAttemptService attempts;
+    private final OmgCheckMacSigner signer;
+
+    public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
+        this.attempts = attempts;
+        this.signer = signer;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean process(OmgNotifyRequest request) {
+        if (request == null || !request.isValid() || !hasRequiredFields(request)) {
+            log.warn("OMG notify rejected reason=malformed_or_missing_fields");
+            return false;
+        }
+        String merchantTradeNo = request.value("MerchantTradeNo");
+        if (isBlank(merchantTradeNo) || merchantTradeNo.length() > 20) {
+            log.warn("OMG notify rejected reason=invalid_merchant_trade_no");
+            return false;
+        }
+
+        OmgPaymentAttempt discovered = attempts.selectByMerchantTradeNo(merchantTradeNo);
+        if (discovered == null) {
+            log.warn("OMG notify rejected merchantTradeNo={} reason=attempt_not_found", merchantTradeNo);
+            return false;
+        }
+        if (factsDoNotIdentifySameAttempt(request, discovered)) {
+            log.warn("OMG notify rejected merchantTradeNo={} reason=prelock_fact_mismatch", merchantTradeNo);
+            return false;
+        }
+        OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId());
+        OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(merchantTradeNo);
+        if (attempt == null || !verifyTrust(request, attempt)) {
+            log.warn("OMG notify rejected merchantTradeNo={} reason=trust_validation_failed", merchantTradeNo);
+            return false;
+        }
+
+        ParsedFacts facts;
+        try {
+            facts = parseFacts(request);
+        } catch (IllegalArgumentException exception) {
+            log.warn("OMG notify rejected merchantTradeNo={} reason=invalid_gateway_facts", merchantTradeNo);
+            return false;
+        }
+        if (facts.rtnCode != 1) {
+            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;
+            }
+            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;
+            }
+            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;
+        }
+
+        if (order == null) {
+            log.error("OMG paid notify rejected merchantTradeNo={} reason=order_not_found", merchantTradeNo);
+            return false;
+        }
+        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;
+            }
+            log.info("OMG paid duplicate accepted merchantTradeNo={}, tradeNo={}", merchantTradeNo, facts.tradeNo);
+            return true;
+        }
+
+        requireSingleUpdate(attempts.markPaid(toUpdate(attempt, facts)), "mark paid");
+        attempts.supersedeOtherCreated(attempt.getDdId(), attempt.getId());
+        if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
+            requireSingleUpdate(attempts.markOrderPaid(attempt.getDdId()), "mark order paid");
+        }
+        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);
+        }
+        if (otherPaid > 0) {
+            log.error("OMG multiple paid attempts orderId={}, merchantTradeNo={}, tradeNo={}, otherPaidCount={}",
+                    attempt.getDdId(), merchantTradeNo, 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;
+    }
+
+    private boolean verifyTrust(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
+        if (!request.value("MerchantID").equals(attempt.getMerchantId())) {
+            return false;
+        }
+        Integer amount = parseInteger(request.value("TradeAmt"));
+        if (amount == null || !amount.equals(attempt.getAmount())) {
+            return false;
+        }
+        String actual = request.value("CheckMacValue");
+        if (actual == null || !actual.matches("(?i)[0-9a-f]{64}")) {
+            return false;
+        }
+        String expected = signer.sign(request.signingFields(),
+                attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot());
+        return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
+                actual.toUpperCase().getBytes(StandardCharsets.US_ASCII));
+    }
+
+    private static boolean factsDoNotIdentifySameAttempt(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
+        Integer amount = parseInteger(request.value("TradeAmt"));
+        return !request.value("MerchantID").equals(attempt.getMerchantId())
+                || amount == null || !amount.equals(attempt.getAmount());
+    }
+
+    private static boolean hasRequiredFields(OmgNotifyRequest request) {
+        return REQUIRED_FIELDS.stream().allMatch(request::contains);
+    }
+
+    private static ParsedFacts parseFacts(OmgNotifyRequest request) {
+        Integer rtnCode = requiredInteger(request.value("RtnCode"));
+        Integer fee = requiredInteger(request.value("PaymentTypeChargeFee"));
+        if (fee < 0) {
+            throw new IllegalArgumentException("invalid PaymentTypeChargeFee");
+        }
+        Integer simulatePaid = requiredInteger(request.value("SimulatePaid"));
+        if (simulatePaid != 0 && simulatePaid != 1) {
+            throw new IllegalArgumentException("invalid SimulatePaid");
+        }
+        String tradeNo = request.value("TradeNo");
+        if ((rtnCode == 1 && isBlank(tradeNo)) || (tradeNo != null && tradeNo.length() > 20)) {
+            throw new IllegalArgumentException("invalid TradeNo");
+        }
+        if (isBlank(tradeNo)) {
+            tradeNo = null;
+        }
+        String paymentType = request.value("PaymentType");
+        if (isBlank(paymentType) || paymentType.length() > 20) {
+            throw new IllegalArgumentException("invalid PaymentType");
+        }
+        Date paymentDate = parseDate(request.value("PaymentDate"), rtnCode == 1);
+        Date tradeDate = parseDate(request.value("TradeDate"), true);
+        String rtnMsg = request.value("RtnMsg");
+        if (rtnMsg == null || rtnMsg.length() > 200) {
+            throw new IllegalArgumentException("invalid RtnMsg");
+        }
+        return new ParsedFacts(rtnCode, rtnMsg, tradeNo, paymentType,
+                paymentDate, tradeDate, fee, simulatePaid);
+    }
+
+    private static OmgPaymentAttempt toUpdate(OmgPaymentAttempt attempt, ParsedFacts 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.setLastNotifyTime(new Date());
+        update.setUpdateTime(new Date());
+        return update;
+    }
+
+    private static Date parseDate(String value, boolean required) {
+        if (isBlank(value)) {
+            if (required) {
+                throw new IllegalArgumentException("required date missing");
+            }
+            return null;
+        }
+        try {
+            return Date.from(LocalDateTime.parse(value, OMG_DATE).atZone(TAIPEI).toInstant());
+        } catch (DateTimeParseException exception) {
+            throw new IllegalArgumentException("invalid date", exception);
+        }
+    }
+
+    private static Integer requiredInteger(String value) {
+        Integer parsed = parseInteger(value);
+        if (parsed == null) {
+            throw new IllegalArgumentException("invalid integer");
+        }
+        return parsed;
+    }
+
+    private static Integer parseInteger(String value) {
+        try {
+            return isBlank(value) ? null : Integer.valueOf(value);
+        } catch (NumberFormatException exception) {
+            return null;
+        }
+    }
+
+    private static void requireSingleUpdate(int count, String action) {
+        if (count != 1) {
+            throw new IllegalStateException("OMG notify failed to " + action);
+        }
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.isBlank();
+    }
+
+    private static String limit(String value, int length) {
+        return value == null || value.length() <= length ? value : value.substring(0, length);
+    }
+
+    private record ParsedFacts(int rtnCode, String rtnMsg, String tradeNo, String paymentType,
+                               Date paymentDate, Date tradeDate, int paymentTypeChargeFee, int simulatePaid) {
+    }
+}

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgNotifyField.java

@@ -0,0 +1,5 @@
+package com.ruoyi.app.omgpay.dto;
+
+/** One decoded field from the OMG form-urlencoded callback. */
+public record OmgNotifyField(String name, String value) {
+}

+ 64 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgNotifyRequest.java

@@ -0,0 +1,64 @@
+package com.ruoyi.app.omgpay.dto;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Controller DTO containing the exact callback body and every decoded field.
+ * Unknown and empty fields are retained because OMG includes them in CheckMacValue.
+ */
+public class OmgNotifyRequest {
+    private final String sourceIp;
+    private final String rawForm;
+    private final List<OmgNotifyField> fields;
+    private final String invalidReason;
+
+    public OmgNotifyRequest(String sourceIp, String rawForm, List<OmgNotifyField> fields, String invalidReason) {
+        this.sourceIp = sourceIp;
+        this.rawForm = rawForm == null ? "" : rawForm;
+        this.fields = fields == null ? List.of() : List.copyOf(fields);
+        this.invalidReason = invalidReason;
+    }
+
+    public String getSourceIp() {
+        return sourceIp;
+    }
+
+    public String getRawForm() {
+        return rawForm;
+    }
+
+    public List<OmgNotifyField> getFields() {
+        return fields;
+    }
+
+    public boolean isValid() {
+        return invalidReason == null;
+    }
+
+    public String getInvalidReason() {
+        return invalidReason;
+    }
+
+    public boolean contains(String name) {
+        return fields.stream().anyMatch(field -> field.name().equals(name));
+    }
+
+    public String value(String name) {
+        return fields.stream().filter(field -> field.name().equals(name))
+                .map(OmgNotifyField::value).findFirst().orElse(null);
+    }
+
+    /** Builds the signer input only after the request has crossed the Controller boundary. */
+    public Map<String, String> signingFields() {
+        LinkedHashMap<String, String> result = new LinkedHashMap<>();
+        for (OmgNotifyField field : fields) {
+            if (!"CheckMacValue".equals(field.name())) {
+                result.put(field.name(), field.value());
+            }
+        }
+        return Collections.unmodifiableMap(result);
+    }
+}

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

@@ -225,6 +225,7 @@ omg.payment.storeId.required=OMG 门店 ID 不能为空
 omg.payment.merchantId.required=OMG 商户号不能为空
 omg.payment.merchantId.invalid=OMG 商户号必须为最多 10 位英数字
 omg.payment.amount.invalid=OMG 支付金额必须大于 0
+omg.payment.credential.snapshot.required=OMG 支付凭证快照不能为空
 omg.pay.auth.required=请先登录
 omg.pay.order.required=订单号不能为空
 omg.pay.order.not.available=订单不存在或无权操作

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

@@ -228,6 +228,7 @@ omg.payment.storeId.required=OMG store id is required
 omg.payment.merchantId.required=OMG merchant id is required
 omg.payment.merchantId.invalid=OMG merchant id must be at most 10 letters or digits
 omg.payment.amount.invalid=OMG payment amount must be greater than 0
+omg.payment.credential.snapshot.required=OMG payment credential snapshots are required
 omg.pay.auth.required=Please sign in first
 omg.pay.order.required=The order number is required
 omg.pay.order.not.available=The order does not exist or is not available to this user

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

@@ -228,6 +228,7 @@ omg.payment.storeId.required=ID cửa hàng OMG là bắt buộc
 omg.payment.merchantId.required=Mã thương nhân OMG là bắt buộc
 omg.payment.merchantId.invalid=Mã thương nhân OMG phải có tối đa 10 chữ cái hoặc chữ số
 omg.payment.amount.invalid=Số tiền thanh toán OMG phải lớn hơn 0
+omg.payment.credential.snapshot.required=Ảnh chụp thông tin xác thực thanh toán OMG là bắt buộc
 omg.pay.auth.required=Vui lòng đăng nhập trước
 omg.pay.order.required=Vui lòng nhập mã đơn hàng
 omg.pay.order.not.available=Đơn hàng không tồn tại hoặc người dùng không có quyền truy cập

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

@@ -229,6 +229,7 @@ omg.payment.storeId.required=OMG 门店 ID 不能为空
 omg.payment.merchantId.required=OMG 商户号不能为空
 omg.payment.merchantId.invalid=OMG 商户号必须为最多 10 位英数字
 omg.payment.amount.invalid=OMG 支付金额必须大于 0
+omg.payment.credential.snapshot.required=OMG 支付凭证快照不能为空
 omg.pay.auth.required=请先登录
 omg.pay.order.required=订单号不能为空
 omg.pay.order.not.available=订单不存在或无权操作

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

@@ -229,6 +229,7 @@ omg.payment.storeId.required=OMG 門店 ID 不能為空
 omg.payment.merchantId.required=OMG 商戶號不能為空
 omg.payment.merchantId.invalid=OMG 商戶號必須為最多 10 位英數字
 omg.payment.amount.invalid=OMG 支付金額必須大於 0
+omg.payment.credential.snapshot.required=OMG 支付憑證快照不能為空
 omg.pay.auth.required=請先登入
 omg.pay.order.required=訂單號不能為空
 omg.pay.order.not.available=訂單不存在或無權操作

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

@@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.*;
 
 class OmgLegacyRetirementTest {
     @Test
-    void oldRuntimeClassesAreAbsentAndNewControllerExposesOnlyCreate() {
+    void oldRuntimeClassesAreAbsentAndNewControllerExposesOnlyCreateAndNotify() {
         assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.app.pay.OmgPayController"));
         assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.app.task.OmgReconcileTask"));
         assertThrows(ClassNotFoundException.class, () -> Class.forName("com.ruoyi.system.domain.PosOrderOmgPayment"));
@@ -21,10 +21,10 @@ class OmgLegacyRetirementTest {
                 .filter(method -> method.isAnnotationPresent(PostMapping.class))
                 .flatMap(method -> Arrays.stream(method.getAnnotation(PostMapping.class).value()))
                 .collect(Collectors.toSet());
-        assertEquals(Set.of("/create"), postPaths);
+        assertEquals(Set.of("/create", "/notify"), postPaths);
         assertTrue(Arrays.stream(OmgPaymentController.class.getDeclaredMethods())
                 .map(method -> method.getName().toLowerCase())
-                .noneMatch(name -> name.contains("notify") || name.contains("query")
-                        || name.contains("paymentinfo") || name.contains("return") || name.contains("refund")));
+                .noneMatch(name -> name.contains("query") || name.contains("paymentinfo")
+                        || name.contains("return") || name.contains("refund")));
     }
 }

+ 50 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgNotifyFormParserTest.java

@@ -0,0 +1,50 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class OmgNotifyFormParserTest {
+
+    private final OmgNotifyFormParser parser = new OmgNotifyFormParser();
+
+    @Test
+    void keepsUnknownAndEmptyFieldsForSignatureAndRawReplay() {
+        String raw = "MerchantTradeNo=OMG123&CustomField1=&FutureField=A%2BB";
+
+        OmgNotifyRequest request = parser.parse(raw.getBytes(StandardCharsets.UTF_8), "127.0.0.1",
+                "application/x-www-form-urlencoded; charset=UTF-8");
+
+        assertTrue(request.isValid());
+        assertEquals(raw, request.getRawForm());
+        assertEquals("", request.value("CustomField1"));
+        assertEquals("A+B", request.value("FutureField"));
+        assertEquals(3, request.signingFields().size());
+    }
+
+    @Test
+    void rejectsDuplicateFieldsAndMalformedEncoding() {
+        OmgNotifyRequest duplicate = parser.parse("RtnCode=1&RtnCode=0".getBytes(StandardCharsets.UTF_8),
+                "127.0.0.1", "application/x-www-form-urlencoded");
+        OmgNotifyRequest malformed = parser.parse("RtnMsg=%ZZ".getBytes(StandardCharsets.UTF_8),
+                "127.0.0.1", "application/x-www-form-urlencoded");
+
+        assertFalse(duplicate.isValid());
+        assertFalse(malformed.isValid());
+    }
+
+    @Test
+    void rejectsWrongContentTypeAndOversizedBody() {
+        OmgNotifyRequest wrongType = parser.parse("RtnCode=1".getBytes(StandardCharsets.UTF_8),
+                "127.0.0.1", "application/json");
+        byte[] oversized = new byte[OmgNotifyFormParser.MAX_FORM_BYTES + 1];
+        OmgNotifyRequest tooLarge = parser.parse(oversized, "127.0.0.1",
+                "application/x-www-form-urlencoded");
+
+        assertFalse(wrongType.isValid());
+        assertFalse(tooLarge.isValid());
+    }
+}

+ 28 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgNotifyRawBodyFilterTest.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.omgpay;
+
+import jakarta.servlet.ServletRequest;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class OmgNotifyRawBodyFilterTest {
+
+    @Test
+    void preservesNotifyBodyForLaterMvcResolution() throws Exception {
+        String raw = "MerchantTradeNo=FULL&CustomField1=";
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", "/pay/omg/notify");
+        request.setContentType("application/x-www-form-urlencoded");
+        request.setContent(raw.getBytes(StandardCharsets.UTF_8));
+        MockFilterChain chain = new MockFilterChain();
+
+        new OmgNotifyRawBodyFilter().doFilter(request, new MockHttpServletResponse(), chain);
+
+        ServletRequest wrapped = chain.getRequest();
+        assertEquals(raw, new String(wrapped.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
+    }
+}

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

@@ -8,6 +8,7 @@ import com.ruoyi.app.omgpay.dto.OmgCreatePaymentResponse;
 import com.ruoyi.app.omgpay.dto.OmgPaymentErrorResponse;
 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.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -61,7 +62,7 @@ class OmgPaymentControllerTest {
         OmgCreatePaymentResponse response = new OmgCreatePaymentResponse("stage", Map.of("CheckMacValue", "A".repeat(64)));
         when(service.create(5L, "DD-1\r\nforged=true")).thenReturn(new OmgPaymentCreateOutcome(
                 response, 9L, "DD-1", 5L, 77L, 100, "OMG12***4567"));
-        OmgPaymentController controller = new OmgPaymentController(resolver, service);
+        OmgPaymentController controller = controller(resolver, service);
         OmgCreatePaymentRequest request = new OmgCreatePaymentRequest();
         request.setOrderId("DD-1\r\nforged=true");
         ListAppender<ILoggingEvent> logs = captureLogs();
@@ -84,7 +85,7 @@ class OmgPaymentControllerTest {
         OmgPaymentCreateService service = mock(OmgPaymentCreateService.class);
         when(resolver.requireUserId(anyString())).thenReturn(5L);
         when(service.create(5L, null)).thenThrow(new OmgPaymentBusinessException(OmgPaymentErrorCode.ORDER_REQUIRED));
-        OmgPaymentController controller = new OmgPaymentController(resolver, service);
+        OmgPaymentController controller = controller(resolver, service);
         AjaxResult business = controller.create("token", null);
         assertEquals("ORDER_REQUIRED", ((OmgPaymentErrorResponse) business.get("data")).status());
 
@@ -109,7 +110,7 @@ class OmgPaymentControllerTest {
             OmgCreatePaymentRequest request = new OmgCreatePaymentRequest();
             request.setOrderId("DD-1");
 
-            AjaxResult result = new OmgPaymentController(resolver, service).create("token", request);
+            AjaxResult result = controller(resolver, service).create("token", request);
 
             assertEquals(code.name(), ((OmgPaymentErrorResponse) result.get("data")).status());
         }
@@ -120,7 +121,7 @@ class OmgPaymentControllerTest {
         OmgPaymentTokenUserResolver resolver = mock(OmgPaymentTokenUserResolver.class);
         when(resolver.requireUserId("NEVER_LOG_THIS")).thenThrow(
                 new OmgPaymentBusinessException(OmgPaymentErrorCode.AUTH_REQUIRED));
-        OmgPaymentController controller = new OmgPaymentController(resolver, mock(OmgPaymentCreateService.class));
+        OmgPaymentController controller = controller(resolver, mock(OmgPaymentCreateService.class));
         ListAppender<ILoggingEvent> logs = captureLogs();
 
         AjaxResult result = controller.create("NEVER_LOG_THIS", null);
@@ -136,4 +137,10 @@ class OmgPaymentControllerTest {
         logger.addAppender(appender);
         return appender;
     }
+
+    private static OmgPaymentController controller(OmgPaymentTokenUserResolver resolver,
+                                                   OmgPaymentCreateService service) {
+        return new OmgPaymentController(resolver, service, mock(OmgPaymentNotifyService.class),
+                mock(OmgIpnAuditService.class));
+    }
 }

+ 14 - 7
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java

@@ -47,7 +47,8 @@ class OmgPaymentCreateServiceTest {
                 () -> service.create(5L, "DD-1"));
 
         assertEquals(expected, error.getCode());
-        verify(attempts, never()).createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt());
+        verify(attempts, never()).createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString());
         verifyNoInteractions(generator, formFactory);
     }
 
@@ -72,7 +73,8 @@ class OmgPaymentCreateServiceTest {
         when(generator.generate()).thenReturn("OMG12345678901234567");
         when(formFactory.create(anyString(), anyInt(), anyString(), anyString(), anyString(), anyString()))
                 .thenReturn(new OmgPaymentForm(OmgPaymentFormFactory.STAGE_GATEWAY_URL, Map.of("CheckMacValue", "A".repeat(64))));
-        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt())).thenReturn(attempt);
+        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString())).thenReturn(attempt);
 
         OmgPaymentCreateOutcome outcome = service.create(5L, "DD-1");
 
@@ -87,7 +89,8 @@ class OmgPaymentCreateServiceTest {
         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);
+        orderOfCalls.verify(attempts).createCreated("DD-1", "OMG12345678901234567", 77L, "1000031", 100,
+                "KEY", "IV");
     }
 
     @Test
@@ -97,7 +100,8 @@ class OmgPaymentCreateServiceTest {
         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()))
+        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString()))
                 .thenThrow(new DuplicateKeyException("collision"));
         when(attempts.selectActiveCreatedByDdIdForUpdate("DD-1")).thenReturn(null, new OmgPaymentAttempt());
 
@@ -116,7 +120,8 @@ class OmgPaymentCreateServiceTest {
                 .thenReturn(new OmgPaymentForm("stage", Map.of()));
         OmgPaymentAttempt inserted = new OmgPaymentAttempt();
         inserted.setId(11L);
-        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt()))
+        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString()))
                 .thenThrow(new DuplicateKeyException("trade collision")).thenReturn(inserted);
         when(attempts.selectByMerchantTradeNoForUpdate("OMG11111111111111111"))
                 .thenReturn(new OmgPaymentAttempt());
@@ -134,7 +139,8 @@ class OmgPaymentCreateServiceTest {
                 "OMG33333333333333333");
         when(formFactory.create(anyString(), anyInt(), anyString(), anyString(), anyString(), anyString()))
                 .thenReturn(new OmgPaymentForm("stage", Map.of()));
-        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt()))
+        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString()))
                 .thenThrow(new DuplicateKeyException("trade collision"));
         when(attempts.selectByMerchantTradeNoForUpdate(anyString())).thenReturn(new OmgPaymentAttempt());
 
@@ -183,7 +189,8 @@ class OmgPaymentCreateServiceTest {
 
         assertEquals(STORE_CREDENTIAL_UNAVAILABLE, error.getCode());
         verifyNoInteractions(generator, formFactory);
-        verify(attempts, never()).createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt());
+        verify(attempts, never()).createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString());
     }
 
     private static OmgPaymentOrderSnapshot payableOrder() {

+ 81 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyControllerTest.java

@@ -0,0 +1,81 @@
+package com.ruoyi.app.omgpay;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.ruoyi.app.omgpay.dto.OmgNotifyField;
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.system.omgpay.service.OmgIpnAuditService;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class OmgPaymentNotifyControllerTest {
+
+    @Test
+    void exposesAnonymousDtoOnlyNotifyContract() throws Exception {
+        Method notify = OmgPaymentController.class.getDeclaredMethod("notify", OmgNotifyRequest.class);
+
+        assertNotNull(notify.getAnnotation(Anonymous.class));
+        assertArrayEquals(new String[]{"/notify"}, notify.getAnnotation(PostMapping.class).value());
+        assertNotNull(notify.getParameters()[0].getAnnotation(OmgNotifyForm.class));
+        assertTrue(java.util.Arrays.stream(notify.getParameterTypes())
+                .noneMatch(Map.class::isAssignableFrom));
+        assertTrue(java.util.Arrays.stream(notify.getParameterTypes())
+                .noneMatch(type -> type.getName().contains("HttpServletRequest")));
+    }
+
+    @Test
+    void logsAndAuditsCompleteRawFormBeforeReturningOk() {
+        OmgPaymentNotifyService notifyService = mock(OmgPaymentNotifyService.class);
+        OmgIpnAuditService audit = mock(OmgIpnAuditService.class);
+        OmgPaymentController controller = controller(notifyService, audit);
+        String raw = "MerchantTradeNo=FULL123&CustomField1=&CheckMacValue=ABC";
+        OmgNotifyRequest request = new OmgNotifyRequest("127.0.0.1", raw,
+                List.of(new OmgNotifyField("MerchantTradeNo", "FULL123")), null);
+        when(notifyService.process(request)).thenReturn(true);
+        ListAppender<ILoggingEvent> logs = captureLogs();
+
+        ResponseEntity<String> response = controller.notify(request);
+
+        assertEquals("1|OK", response.getBody());
+        verify(audit).append("127.0.0.1", raw);
+        assertTrue(logs.list.stream().anyMatch(event -> event.getFormattedMessage().contains(raw)));
+    }
+
+    @Test
+    void ipnFailureDoesNotBlockPaymentButTrustOrTransactionFailureReturnsError() {
+        OmgPaymentNotifyService notifyService = mock(OmgPaymentNotifyService.class);
+        OmgIpnAuditService audit = mock(OmgIpnAuditService.class);
+        OmgPaymentController controller = controller(notifyService, audit);
+        OmgNotifyRequest request = new OmgNotifyRequest("127.0.0.1", "RtnCode=1", List.of(), null);
+        doThrow(new IllegalStateException("audit unavailable")).when(audit).append(anyString(), anyString());
+        when(notifyService.process(request)).thenReturn(true, false).thenThrow(new IllegalStateException("db"));
+
+        assertEquals("1|OK", controller.notify(request).getBody());
+        assertEquals("0|ERROR", controller.notify(request).getBody());
+        assertEquals("0|ERROR", controller.notify(request).getBody());
+    }
+
+    private static OmgPaymentController controller(OmgPaymentNotifyService notifyService, OmgIpnAuditService audit) {
+        return new OmgPaymentController(mock(OmgPaymentTokenUserResolver.class),
+                mock(OmgPaymentCreateService.class), notifyService, audit);
+    }
+
+    private static ListAppender<ILoggingEvent> captureLogs() {
+        Logger logger = (Logger) LoggerFactory.getLogger(OmgPaymentController.class);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        return appender;
+    }
+}

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

@@ -0,0 +1,180 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgNotifyField;
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+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.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class OmgPaymentNotifyServiceTest {
+    private static final String HASH_KEY = "5294y06JbISpM5x9";
+    private static final String HASH_IV = "v77hoKGq4kWxNNIS";
+
+    private IOmgPaymentAttemptService attempts;
+    private OmgPaymentNotifyService service;
+    private OmgPaymentAttempt attempt;
+
+    @BeforeEach
+    void setUp() {
+        attempts = mock(IOmgPaymentAttemptService.class);
+        service = new OmgPaymentNotifyService(attempts, new OmgCheckMacSigner());
+        attempt = new OmgPaymentAttempt();
+        attempt.setId(7L);
+        attempt.setDdId("DD-1");
+        attempt.setMerchantTradeNo("OMG123");
+        attempt.setMerchantId("1000031");
+        attempt.setAmount(100);
+        attempt.setHashKeySnapshot(HASH_KEY);
+        attempt.setHashIvSnapshot(HASH_IV);
+        attempt.setAttemptStatus(0);
+        when(attempts.selectByMerchantTradeNo("OMG123")).thenReturn(attempt);
+        when(attempts.selectByMerchantTradeNoForUpdate("OMG123")).thenReturn(attempt);
+    }
+
+    @Test
+    void successfulCallbackUsesSnapshotAndMarksOnlyPaymentFacts() {
+        OmgPaymentOrderSnapshot order = order(4L, 0L);
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order);
+        when(attempts.markPaid(any())).thenReturn(1);
+        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
+
+        assertTrue(service.process(signedRequest(1, 0, 100)));
+
+        var facts = org.mockito.ArgumentCaptor.forClass(OmgPaymentAttempt.class);
+        verify(attempts).markPaid(facts.capture());
+        assertEquals("GW123", facts.getValue().getTradeNo());
+        assertEquals(1, facts.getValue().getRtnCode());
+        assertEquals(0, facts.getValue().getSimulatePaid());
+        verify(attempts).supersedeOtherCreated("DD-1", 7L);
+        verify(attempts).markOrderPaid("DD-1");
+    }
+
+    @Test
+    void simulatedSuccessOnCancelledOrderIsStillPaid() {
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order(4L, 0L));
+        when(attempts.markPaid(any())).thenReturn(1);
+        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
+        when(attempts.countOtherPaidAttempts("DD-1", 7L)).thenReturn(1);
+
+        assertTrue(service.process(signedRequest(1, 1, 100)));
+
+        var facts = org.mockito.ArgumentCaptor.forClass(OmgPaymentAttempt.class);
+        verify(attempts).markPaid(facts.capture());
+        assertEquals(1, facts.getValue().getSimulatePaid());
+    }
+
+    @Test
+    void failureMarksAttemptFailedWithoutChangingOrder() {
+        when(attempts.markFailed(any())).thenReturn(1);
+
+        assertTrue(service.process(signedRequest(10200095, 0, 100)));
+
+        verify(attempts).markFailed(any());
+        verify(attempts, never()).markOrderPaid(anyString());
+    }
+
+    @Test
+    void supersededAttemptAcceptsFailureWithoutLosingSupersededState() {
+        attempt.setAttemptStatus(3);
+
+        assertTrue(service.process(signedRequest(10200095, 0, 100)));
+
+        verify(attempts, never()).markFailed(any());
+        verify(attempts, never()).markOrderPaid(anyString());
+    }
+
+    @Test
+    void paidAttemptCannotBeDowngradedByLateFailure() {
+        attempt.setAttemptStatus(1);
+
+        assertTrue(service.process(signedRequest(10200095, 0, 100)));
+
+        verify(attempts, never()).markFailed(any());
+        verify(attempts, never()).markOrderPaid(anyString());
+    }
+
+    @Test
+    void failedAttemptMayLaterBecomePaid() {
+        attempt.setAttemptStatus(2);
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order(0L, 0L));
+        when(attempts.markPaid(any())).thenReturn(1);
+        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
+
+        assertTrue(service.process(signedRequest(1, 0, 100)));
+
+        verify(attempts).markPaid(any());
+        verify(attempts).markOrderPaid("DD-1");
+    }
+
+    @Test
+    void rejectsSignatureMerchantAndAmountMismatchWithoutMutation() {
+        OmgNotifyRequest badSignature = signedRequest(1, 0, 100);
+        List<OmgNotifyField> changed = new ArrayList<>(badSignature.getFields());
+        changed.set(changed.size() - 1, new OmgNotifyField("CheckMacValue", "0".repeat(64)));
+        assertFalse(service.process(new OmgNotifyRequest("127.0.0.1", "raw", changed, null)));
+        assertFalse(service.process(signedRequest(1, 0, 101)));
+        assertFalse(service.process(signedRequest(1, 0, 100, "OTHER")));
+
+        verify(attempts, never()).markPaid(any());
+        verify(attempts, never()).markFailed(any());
+    }
+
+    @Test
+    void validExtraFieldIsRequiredBySignatureVerification() {
+        OmgNotifyRequest signed = signedRequest(1, 0, 100);
+        List<OmgNotifyField> fieldsWithoutExtra = signed.getFields().stream()
+                .filter(field -> !"FutureEmptyField".equals(field.name())).toList();
+
+        assertFalse(service.process(new OmgNotifyRequest("127.0.0.1", "raw", fieldsWithoutExtra, null)));
+
+        verify(attempts, never()).markPaid(any());
+    }
+
+    private OmgNotifyRequest signedRequest(int rtnCode, int simulatePaid, int amount) {
+        return signedRequest(rtnCode, simulatePaid, amount, "1000031");
+    }
+
+    private OmgNotifyRequest signedRequest(int rtnCode, int simulatePaid, int amount, String merchantId) {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", merchantId);
+        fields.put("MerchantTradeNo", "OMG123");
+        fields.put("StoreID", "");
+        fields.put("RtnCode", String.valueOf(rtnCode));
+        fields.put("RtnMsg", rtnCode == 1 ? "Succeeded" : "Failed");
+        fields.put("TradeNo", "GW123");
+        fields.put("TradeAmt", String.valueOf(amount));
+        fields.put("PaymentDate", rtnCode == 1 ? "2026/08/13 12:00:00" : "");
+        fields.put("PaymentType", "Credit_CreditCard");
+        fields.put("PaymentTypeChargeFee", "0");
+        fields.put("TradeDate", "2026/08/13 11:59:00");
+        fields.put("SimulatePaid", String.valueOf(simulatePaid));
+        fields.put("CustomField1", "");
+        fields.put("CustomField2", "");
+        fields.put("CustomField3", "");
+        fields.put("CustomField4", "");
+        fields.put("FutureEmptyField", "");
+        fields.put("CheckMacValue", new OmgCheckMacSigner().sign(fields, HASH_KEY, HASH_IV));
+        List<OmgNotifyField> decoded = fields.entrySet().stream()
+                .map(entry -> new OmgNotifyField(entry.getKey(), entry.getValue())).toList();
+        return new OmgNotifyRequest("127.0.0.1", "raw", decoded, null);
+    }
+
+    private static OmgPaymentOrderSnapshot order(long state, long payStatus) {
+        OmgPaymentOrderSnapshot order = new OmgPaymentOrderSnapshot();
+        order.setDdId("DD-1");
+        order.setState(state);
+        order.setPayStatus(payStatus);
+        return order;
+    }
+}

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

@@ -23,8 +23,19 @@ public class OmgPaymentAttempt {
     private Long storeId;
     private String merchantId;
     private Integer amount;
+    private String hashKeySnapshot;
+    private String hashIvSnapshot;
     private Integer attemptStatus;
     private String activeDdId;
+    private String tradeNo;
+    private Integer rtnCode;
+    private String rtnMsg;
+    private String paymentType;
+    private Date paymentDate;
+    private Date tradeDate;
+    private Integer paymentTypeChargeFee;
+    private Integer simulatePaid;
+    private Date lastNotifyTime;
     @TableField(fill = FieldFill.INSERT)
     private Date createTime;
     @TableField(fill = FieldFill.INSERT_UPDATE)

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

@@ -16,5 +16,19 @@ public interface OmgPaymentAttemptMapper {
     /** Current read used after an insert conflict to classify a committed trade-number collision. */
     OmgPaymentAttempt selectByMerchantTradeNoForUpdate(@Param("merchantTradeNo") String merchantTradeNo);
 
+    /** Preliminary read used to discover the order before locks are acquired in order-first sequence. */
+    OmgPaymentAttempt selectByMerchantTradeNo(@Param("merchantTradeNo") String merchantTradeNo);
+
     int insertCreated(OmgPaymentAttempt row);
+
+    int markPaid(OmgPaymentAttempt row);
+
+    int markFailed(OmgPaymentAttempt row);
+
+    int supersedeOtherCreated(@Param("ddId") String ddId, @Param("paidAttemptId") Long paidAttemptId,
+                              @Param("updateTime") java.util.Date updateTime);
+
+    int markOrderPaid(@Param("ddId") String ddId);
+
+    int countOtherPaidAttempts(@Param("ddId") String ddId, @Param("attemptId") Long attemptId);
 }

+ 14 - 1
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java

@@ -15,6 +15,19 @@ public interface IOmgPaymentAttemptService {
     /** Current read that classifies a committed MerchantTradeNo unique-key collision. */
     OmgPaymentAttempt selectByMerchantTradeNoForUpdate(String merchantTradeNo);
 
+    OmgPaymentAttempt selectByMerchantTradeNo(String merchantTradeNo);
+
     OmgPaymentAttempt createCreated(String ddId, String merchantTradeNo, Long storeId,
-                                    String merchantId, Integer amount);
+                                    String merchantId, Integer amount,
+                                    String hashKeySnapshot, String hashIvSnapshot);
+
+    int markPaid(OmgPaymentAttempt paymentFacts);
+
+    int markFailed(OmgPaymentAttempt paymentFacts);
+
+    int supersedeOtherCreated(String ddId, Long paidAttemptId);
+
+    int markOrderPaid(String ddId);
+
+    int countOtherPaidAttempts(String ddId, Long attemptId);
 }

+ 31 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/OmgIpnAuditService.java

@@ -0,0 +1,31 @@
+package com.ruoyi.system.omgpay.service;
+
+import com.ruoyi.system.domain.IpnLog;
+import com.ruoyi.system.service.IIpnLogService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+
+/** Appends every OMG callback to the trusted existing IPN log in its own transaction. */
+@Service
+public class OmgIpnAuditService {
+    private final IIpnLogService ipnLogService;
+
+    public OmgIpnAuditService(IIpnLogService ipnLogService) {
+        this.ipnLogService = ipnLogService;
+    }
+
+    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
+    public void append(String ip, String rawForm) {
+        IpnLog row = new IpnLog();
+        row.setIp(ip == null ? "unknown" : ip);
+        row.setCretim(new Date());
+        row.setIpnLog(rawForm == null ? "" : rawForm);
+        row.setType("omg");
+        if (ipnLogService.insertIpnLog(row) != 1) {
+            throw new IllegalStateException("OMG IPN log insert returned no row");
+        }
+    }
+}

+ 42 - 3
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java

@@ -41,10 +41,18 @@ public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
                 ? null : mapper.selectByMerchantTradeNoForUpdate(merchantTradeNo.trim());
     }
 
+    @Override
+    public OmgPaymentAttempt selectByMerchantTradeNo(String merchantTradeNo) {
+        return StrUtil.isBlank(merchantTradeNo)
+                ? null : mapper.selectByMerchantTradeNo(merchantTradeNo.trim());
+    }
+
     @Override
     public OmgPaymentAttempt createCreated(String ddId, String merchantTradeNo, Long storeId,
-                                           String merchantId, Integer amount) {
-        validateCreatedFacts(ddId, merchantTradeNo, storeId, merchantId, amount);
+                                           String merchantId, Integer amount,
+                                           String hashKeySnapshot, String hashIvSnapshot) {
+        validateCreatedFacts(ddId, merchantTradeNo, storeId, merchantId, amount,
+                hashKeySnapshot, hashIvSnapshot);
         Date now = new Date();
         OmgPaymentAttempt row = new OmgPaymentAttempt();
         row.setDdId(ddId.trim());
@@ -52,6 +60,8 @@ public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
         row.setStoreId(storeId);
         row.setMerchantId(merchantId.trim());
         row.setAmount(amount);
+        row.setHashKeySnapshot(hashKeySnapshot);
+        row.setHashIvSnapshot(hashIvSnapshot);
         row.setAttemptStatus(ATTEMPT_STATUS_CREATED);
         row.setCreateTime(now);
         row.setUpdateTime(now);
@@ -60,7 +70,8 @@ public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
     }
 
     private static void validateCreatedFacts(String ddId, String merchantTradeNo, Long storeId,
-                                             String merchantId, Integer amount) {
+                                             String merchantId, Integer amount,
+                                             String hashKeySnapshot, String hashIvSnapshot) {
         if (StrUtil.isBlank(ddId)) {
             throw new ServiceException(MessageUtils.message("omg.payment.ddid.required"));
         }
@@ -82,5 +93,33 @@ public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
         if (amount == null || amount <= 0) {
             throw new ServiceException(MessageUtils.message("omg.payment.amount.invalid"));
         }
+        if (StrUtil.isBlank(hashKeySnapshot) || StrUtil.isBlank(hashIvSnapshot)) {
+            throw new ServiceException(MessageUtils.message("omg.payment.credential.snapshot.required"));
+        }
+    }
+
+    @Override
+    public int markPaid(OmgPaymentAttempt paymentFacts) {
+        return mapper.markPaid(paymentFacts);
+    }
+
+    @Override
+    public int markFailed(OmgPaymentAttempt paymentFacts) {
+        return mapper.markFailed(paymentFacts);
+    }
+
+    @Override
+    public int supersedeOtherCreated(String ddId, Long paidAttemptId) {
+        return mapper.supersedeOtherCreated(ddId, paidAttemptId, new Date());
+    }
+
+    @Override
+    public int markOrderPaid(String ddId) {
+        return mapper.markOrderPaid(ddId);
+    }
+
+    @Override
+    public int countOtherPaidAttempts(String ddId, Long attemptId) {
+        return mapper.countOtherPaidAttempts(ddId, attemptId);
     }
 }

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

@@ -5,7 +5,12 @@
     <sql id="attemptColumns">
         id, dd_id AS ddId, merchant_trade_no AS merchantTradeNo,
         store_id AS storeId, merchant_id AS merchantId, amount,
+        hash_key_snapshot AS hashKeySnapshot, hash_iv_snapshot AS hashIvSnapshot,
         attempt_status AS attemptStatus, active_dd_id AS activeDdId,
+        trade_no AS tradeNo, rtn_code AS rtnCode, rtn_msg AS rtnMsg,
+        payment_type AS paymentType, payment_date AS paymentDate, trade_date AS tradeDate,
+        payment_type_charge_fee AS paymentTypeChargeFee, simulate_paid AS simulatePaid,
+        last_notify_time AS lastNotifyTime,
         create_time AS createTime, update_time AS updateTime
     </sql>
 
@@ -34,13 +39,57 @@
         LIMIT 1 FOR UPDATE
     </select>
 
+    <select id="selectByMerchantTradeNo"
+            resultType="com.ruoyi.system.omgpay.domain.OmgPaymentAttempt">
+        SELECT <include refid="attemptColumns"/>
+        FROM pos_order_omg_attempt
+        WHERE merchant_trade_no = #{merchantTradeNo}
+        LIMIT 1
+    </select>
+
     <!-- active_dd_id is a DB-generated key invariant, so Java inserts only the created facts. -->
     <insert id="insertCreated" useGeneratedKeys="true" keyProperty="id">
         INSERT INTO pos_order_omg_attempt
           (dd_id, merchant_trade_no, store_id, merchant_id, amount,
-           attempt_status, create_time, update_time)
+           hash_key_snapshot, hash_iv_snapshot, attempt_status, create_time, update_time)
         VALUES
           (#{ddId}, #{merchantTradeNo}, #{storeId}, #{merchantId}, #{amount},
-           #{attemptStatus}, #{createTime}, #{updateTime})
+           #{hashKeySnapshot}, #{hashIvSnapshot}, #{attemptStatus}, #{createTime}, #{updateTime})
     </insert>
+
+    <!-- PAID is irreversible; a late success may upgrade FAILED or SUPERSEDED. -->
+    <update id="markPaid">
+        UPDATE pos_order_omg_attempt
+        SET attempt_status = 1, trade_no = #{tradeNo}, rtn_code = #{rtnCode}, rtn_msg = #{rtnMsg},
+            payment_type = #{paymentType}, payment_date = #{paymentDate}, trade_date = #{tradeDate},
+            payment_type_charge_fee = #{paymentTypeChargeFee}, simulate_paid = #{simulatePaid},
+            last_notify_time = #{lastNotifyTime}, update_time = #{updateTime}
+        WHERE id = #{id} AND attempt_status IN (0, 2, 3)
+    </update>
+
+    <update id="markFailed">
+        UPDATE pos_order_omg_attempt
+        SET attempt_status = 2, trade_no = #{tradeNo}, rtn_code = #{rtnCode}, rtn_msg = #{rtnMsg},
+            payment_type = #{paymentType}, payment_date = #{paymentDate}, trade_date = #{tradeDate},
+            payment_type_charge_fee = #{paymentTypeChargeFee}, simulate_paid = #{simulatePaid},
+            last_notify_time = #{lastNotifyTime}, update_time = #{updateTime}
+        WHERE id = #{id} AND attempt_status IN (0, 2)
+    </update>
+
+    <update id="supersedeOtherCreated">
+        UPDATE pos_order_omg_attempt
+        SET attempt_status = 3, update_time = #{updateTime}
+        WHERE dd_id = #{ddId} AND id != #{paidAttemptId} AND attempt_status = 0
+    </update>
+
+    <!-- Payment notification owns only the payment fact; no order/delivery state is changed here. -->
+    <update id="markOrderPaid">
+        UPDATE pos_order SET pay_status = 1
+        WHERE dd_id = #{ddId} AND pay_status != 1
+    </update>
+
+    <select id="countOtherPaidAttempts" resultType="int">
+        SELECT COUNT(*) FROM pos_order_omg_attempt
+        WHERE dd_id = #{ddId} AND id != #{attemptId} AND attempt_status = 1
+    </select>
 </mapper>

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

@@ -31,9 +31,17 @@ class OmgPaymentAttemptMapperContractTest {
         assertFalse(xml.contains("pos_order_omg_refund"));
         assertTrue(sql.contains("UNIQUE KEY uk_omg_attempt_trade_no"));
         assertTrue(sql.contains("UNIQUE KEY uk_omg_attempt_active_dd"));
+        assertTrue(sql.contains("UNIQUE KEY uk_omg_attempt_gateway_trade_no"));
         assertTrue(sql.contains("IF(attempt_status = 0, dd_id, NULL)"));
+        assertTrue(sql.contains("hash_key_snapshot VARCHAR(64) NOT NULL"));
+        assertTrue(sql.contains("hash_iv_snapshot VARCHAR(64) NOT NULL"));
         assertTrue(sql.contains("merchant_trade_no VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin"));
         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 pos_order SET pay_status = 1"));
+        assertFalse(xml.contains("SET state ="));
+        assertFalse(xml.contains("delivery_status"));
     }
 
     private int countOccurrences(String source, String value) {

+ 42 - 0
ruoyi-system/src/test/java/com/ruoyi/system/omgpay/service/OmgIpnAuditServiceTest.java

@@ -0,0 +1,42 @@
+package com.ruoyi.system.omgpay.service;
+
+import com.ruoyi.system.domain.IpnLog;
+import com.ruoyi.system.service.IIpnLogService;
+import org.junit.jupiter.api.Test;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class OmgIpnAuditServiceTest {
+
+    @Test
+    void appendsCompleteRawCallbackAsIndependentOmgIpn() throws Exception {
+        IIpnLogService logs = mock(IIpnLogService.class);
+        when(logs.insertIpnLog(any())).thenReturn(1);
+        OmgIpnAuditService service = new OmgIpnAuditService(logs);
+
+        service.append("127.0.0.1", "MerchantTradeNo=FULL&CustomField1=");
+
+        var captor = org.mockito.ArgumentCaptor.forClass(IpnLog.class);
+        verify(logs).insertIpnLog(captor.capture());
+        assertEquals("omg", captor.getValue().getType());
+        assertEquals("MerchantTradeNo=FULL&CustomField1=", captor.getValue().getIpnLog());
+        assertNotNull(captor.getValue().getCretim());
+        Method append = OmgIpnAuditService.class.getMethod("append", String.class, String.class);
+        assertEquals(Propagation.REQUIRES_NEW, append.getAnnotation(Transactional.class).propagation());
+    }
+
+    @Test
+    void failsWhenIpnRowWasNotInserted() {
+        IIpnLogService logs = mock(IIpnLogService.class);
+        when(logs.insertIpnLog(any())).thenReturn(0);
+
+        assertThrows(IllegalStateException.class,
+                () -> new OmgIpnAuditService(logs).append("127.0.0.1", "RtnCode=1"));
+    }
+}

+ 16 - 8
ruoyi-system/src/test/java/com/ruoyi/system/omgpay/service/OmgPaymentAttemptServiceTest.java

@@ -31,37 +31,37 @@ class OmgPaymentAttemptServiceTest {
     @Test
     void createCreatedRejectsBlankDdIdUsingMessageUtils() {
         assertLocalizedValidationFailure("omg.payment.ddid.required",
-                () -> service.createCreated("   ", "OMG123", 10L, "M1", 100));
+                () -> service.createCreated("   ", "OMG123", 10L, "M1", 100, "KEY", "IV"));
     }
 
     @Test
     void createCreatedRejectsBlankMerchantTradeNoUsingMessageUtils() {
         assertLocalizedValidationFailure("omg.payment.merchantTradeNo.required",
-                () -> service.createCreated("DD-1", "   ", 10L, "M1", 100));
+                () -> service.createCreated("DD-1", "   ", 10L, "M1", 100, "KEY", "IV"));
     }
 
     @Test
     void createCreatedRejectsInvalidMerchantTradeNoUsingMessageUtils() {
         assertLocalizedValidationFailure("omg.payment.merchantTradeNo.invalid",
-                () -> service.createCreated("DD-1", "omg-invalid", 10L, "M1", 100));
+                () -> service.createCreated("DD-1", "omg-invalid", 10L, "M1", 100, "KEY", "IV"));
     }
 
     @Test
     void createCreatedRejectsNullStoreIdUsingMessageUtils() {
         assertLocalizedValidationFailure("omg.payment.storeId.required",
-                () -> service.createCreated("DD-1", "OMG123", null, "M1", 100));
+                () -> service.createCreated("DD-1", "OMG123", null, "M1", 100, "KEY", "IV"));
     }
 
     @Test
     void createCreatedRejectsBlankMerchantIdUsingMessageUtils() {
         assertLocalizedValidationFailure("omg.payment.merchantId.required",
-                () -> service.createCreated("DD-1", "OMG123", 10L, "   ", 100));
+                () -> service.createCreated("DD-1", "OMG123", 10L, "   ", 100, "KEY", "IV"));
     }
 
     @Test
     void createCreatedRejectsInvalidMerchantIdUsingMessageUtils() {
         assertLocalizedValidationFailure("omg.payment.merchantId.invalid",
-                () -> service.createCreated("DD-1", "OMG123", 10L, "merchant-too-long", 100));
+                () -> service.createCreated("DD-1", "OMG123", 10L, "merchant-too-long", 100, "KEY", "IV"));
     }
 
     @Test
@@ -76,7 +76,7 @@ class OmgPaymentAttemptServiceTest {
     @Test
     void createCreatedRejectsNonPositiveAmountUsingMessageUtils() {
         assertLocalizedValidationFailure("omg.payment.amount.invalid",
-                () -> service.createCreated("DD-1", "OMG123", 10L, "M1", 0));
+                () -> service.createCreated("DD-1", "OMG123", 10L, "M1", 0, "KEY", "IV"));
     }
 
     @Test
@@ -87,13 +87,21 @@ class OmgPaymentAttemptServiceTest {
             return 1;
         });
 
-        OmgPaymentAttempt row = service.createCreated("DD-1", "OMG123", 10L, "M1", 100);
+        OmgPaymentAttempt row = service.createCreated("DD-1", "OMG123", 10L, "M1", 100, "KEY", "IV");
 
         assertEquals(0, row.getAttemptStatus());
         assertEquals(7L, row.getId());
+        assertEquals("KEY", row.getHashKeySnapshot());
+        assertEquals("IV", row.getHashIvSnapshot());
         assertNull(row.getActiveDdId());
     }
 
+    @Test
+    void createCreatedRejectsMissingCredentialSnapshot() {
+        assertLocalizedValidationFailure("omg.payment.credential.snapshot.required",
+                () -> service.createCreated("DD-1", "OMG123", 10L, "M1", 100, " ", "IV"));
+    }
+
     private void assertLocalizedValidationFailure(String key, ThrowingRunnable runnable) {
         try (var messages = mockStatic(MessageUtils.class)) {
             messages.when(() -> MessageUtils.message(key)).thenReturn("localized-" + key);

+ 2 - 2
specs/020-omg-payment-rebuild/quickstart.md

@@ -13,8 +13,8 @@
 ```powershell
 $env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
 $env:Path="$env:JAVA_HOME\bin;$env:Path"
-mvn -pl ruoyi-system -am -Dtest='OmgPaymentAttemptServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test
-mvn -pl ruoyi-admin -am -Dtest='OmgCheckMacSignerTest,OmgPaymentFormFactoryTest,OmgPaymentCreateServiceTest,OmgPaymentControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-system -am -Dtest='OmgPaymentAttemptServiceTest,OmgIpnAuditServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -Dtest='OmgCheckMacSignerTest,OmgPaymentFormFactoryTest,OmgPaymentCreateServiceTest,OmgPaymentControllerTest,OmgNotifyFormParserTest,OmgNotifyRawBodyFilterTest,OmgPaymentNotifyServiceTest,OmgPaymentNotifyControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test
 mvn -pl ruoyi-admin -am -DskipTests package
 ```
 

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

@@ -183,7 +183,7 @@ OMG 向 `ReturnURL` 发送最终付款结果时,系统保存本次 HTTP 回传
 - **FR-038**: 回调状态 MUST 成功优先且不可逆:`CREATED/FAILED -> PAID`,`CREATED -> FAILED`,`PAID` 不得降级。重复通知不得重复改变订单;正确处理或已处理的通知均返回精确 `1|OK`。
 - **FR-039**: 合法成功通知到达时,即使订单已取消也 MUST 记录付款事实并把订单 `pay_status` 更新为 `1`;系统 MUST 输出异常日志,但本阶段不得自动退款。
 - **FR-040**: 一笔尝试成功后 MUST 关闭同订单其他仍活动的尝试。若历史尝试后来也收到合法成功通知,仍 MUST 记录第二笔付款事实并输出严重异常日志,不得因本地单活跃约束丢弃真实资金通知。
-- **FR-041**: 支付尝试 MUST 保存 `trade_no`、`rtn_code`、`rtn_msg`、`payment_type`、`payment_date`、`trade_date`、`payment_type_charge_fee`、`simulate_paid`、`last_notify_time` 和最终回调原文;`trade_no` 非空时全局唯一,防止同一 OMG 交易被绑定到多个本地尝试。
+- **FR-041**: 支付尝试 MUST 保存 `trade_no`、`rtn_code`、`rtn_msg`、`payment_type`、`payment_date`、`trade_date`、`payment_type_charge_fee`、`simulate_paid` 和 `last_notify_time`;完整逐次回调原文只保存于 `ipn_log`。`trade_no` 非空时全局唯一,防止同一 OMG 交易被绑定到多个本地尝试。
 
 ### API Contract
 

+ 15 - 15
specs/020-omg-payment-rebuild/tasks.md

@@ -83,30 +83,30 @@
 
 ## Phase 7: 回调数据与凭证快照
 
-- [ ] T048 [US4] 更新 `pos_order_omg_attempt` DDL:新增 HashKey/HashIV 快照、`PAID/FAILED/SUPERSEDED` 状态、网关结果字段和 `trade_no` 唯一键;只写 `updatesql/sql.md`,不执行 SQL
-- [ ] T049 [US4] 先编写 Entity/Mapper/Service 合约测试源码,覆盖创建密钥快照、交易号锁定、成功不可逆、失败释放、其他活动尝试关闭与订单仅更新 `pay_status`
-- [ ] T050 [US4] 扩展 `OmgPaymentAttempt`、Mapper XML 和 `IOmgPaymentAttemptService`,实现带当前状态条件的原子更新
-- [ ] T051 [US4] 修改创建服务,把本次表单使用的 HashKey/HashIV 传入 `createCreated` 并持久化快照
+- [x] T048 [US4] 更新 `pos_order_omg_attempt` DDL:新增 HashKey/HashIV 快照、`PAID/FAILED/SUPERSEDED` 状态、网关结果字段和 `trade_no` 唯一键;只写 `updatesql/sql.md`,不执行 SQL
+- [x] T049 [US4] 先编写 Entity/Mapper/Service 合约测试源码,覆盖创建密钥快照、交易号锁定、成功不可逆、失败释放、其他活动尝试关闭与订单仅更新 `pay_status`
+- [x] T050 [US4] 扩展 `OmgPaymentAttempt`、Mapper XML 和 `IOmgPaymentAttemptService`,实现带当前状态条件的原子更新
+- [x] T051 [US4] 修改创建服务,把本次表单使用的 HashKey/HashIV 传入 `createCreated` 并持久化快照
 
 ## Phase 8: 原始表单边界与 IPN 流水
 
-- [ ] T052 [US4] 先编写原始 form-urlencoded 解析测试源码,覆盖未知字段、空值、重复字段、非法编码、大小限制和可重放原文
-- [ ] T053 [US4] 实现 `OmgNotifyRequest` 与专用参数解析/ArgumentResolver;Controller 业务入参保持一个 DTO,不使用 Map 或 HttpServletRequest
-- [ ] T054 [US4] 先编写 IPN 独立事务测试源码,覆盖每次请求一行、`type=omg`、完整原文以及日志写入失败不阻断处理
-- [ ] T055 [US4] 在 `com.ruoyi.system.omgpay` 新建 `OmgIpnAuditService`,以 `REQUIRES_NEW` 复用 `IIpnLogService` 写入现有 `ipn_log`
+- [x] T052 [US4] 先编写原始 form-urlencoded 解析测试源码,覆盖未知字段、空值、重复字段、非法编码、大小限制和可重放原文
+- [x] T053 [US4] 实现 `OmgNotifyRequest` 与专用参数解析/ArgumentResolver;Controller 业务入参保持一个 DTO,不使用 Map 或 HttpServletRequest
+- [x] T054 [US4] 先编写 IPN 独立事务测试源码,覆盖每次请求一行、`type=omg`、完整原文以及日志写入失败不阻断处理
+- [x] T055 [US4] 在 `com.ruoyi.system.omgpay` 新建 `OmgIpnAuditService`,以 `REQUIRES_NEW` 复用 `IIpnLogService` 写入现有 `ipn_log`
 
 ## Phase 9: 验签、状态机与公开回调
 
-- [ ] T056 [US4] 先编写回调服务测试源码,覆盖全部实际字段验签、快照密钥、商户/金额匹配、成功、模拟成功、失败、失败后成功、成功后失败、重复、取消后成功和第二笔迟到成功
-- [ ] T057 [US4] 实现 `OmgPaymentNotifyService`,在一个业务事务内锁定尝试、验签、校验并更新尝试与订单;不触发订单/配送状态、推送或退款副作用
-- [ ] T058 [US4] 先编写 Controller 契约测试源码,覆盖匿名 form POST、纯文本 `1|OK`/`0|ERROR`、完整请求日志和无旧回调引用
-- [ ] T059 [US4] 在新 `OmgPaymentController` 增加 `POST /pay/omg/notify`,先独立记录 IPN,再执行业务处理;所有请求完整记录,数据库密钥不入日志
-- [ ] T060 [US4] 静态审计 Controller 无 Map/HttpServletRequest 入参、回调只用新尝试表和可信 `ipn_log`/`pos_store_omg` 创建来源、无旧 OMG 业务引用
+- [x] T056 [US4] 先编写回调服务测试源码,覆盖全部实际字段验签、快照密钥、商户/金额匹配、成功、模拟成功、失败、失败后成功、成功后失败、重复、取消后成功和第二笔迟到成功
+- [x] T057 [US4] 实现 `OmgPaymentNotifyService`,在一个业务事务内锁定尝试、验签、校验并更新尝试与订单;不触发订单/配送状态、推送或退款副作用
+- [x] T058 [US4] 先编写 Controller 契约测试源码,覆盖匿名 form POST、纯文本 `1|OK`/`0|ERROR`、完整请求日志和无旧回调引用
+- [x] T059 [US4] 在新 `OmgPaymentController` 增加 `POST /pay/omg/notify`,先独立记录 IPN,再执行业务处理;所有请求完整记录,数据库密钥不入日志
+- [x] T060 [US4] 静态审计 Controller 无 Map/HttpServletRequest 入参、回调只用新尝试表和可信 `ipn_log`/`pos_store_omg` 创建来源、无旧 OMG 业务引用
 
 ## Phase 10: 回调文档与延后验证
 
-- [ ] T061 更新 `quickstart.md` 的回调重放示例、成功/失败/重复/模拟/取消后付款与 IPN 查询步骤
-- [ ] T062 运行 `git diff --check`、定向 `rg` 和 staged diff 审计;本阶段不运行 Maven、编译或测试
+- [x] T061 更新 `quickstart.md` 的回调重放示例、成功/失败/重复/模拟/取消后付款与 IPN 查询步骤
+- [x] T062 运行 `git diff --check`、定向 `rg` 和 staged diff 审计;本阶段不运行 Maven、编译或测试
 - [ ] T063 在后续 OMG 查询、补单、退款等功能全部调整完成后,统一运行 JDK 21 定向测试、模块构建和完整回归
 
 ## Dependencies & Execution Order

+ 13 - 1
updatesql/sql.md

@@ -688,13 +688,25 @@ CREATE TABLE pos_order_omg_attempt (
   store_id BIGINT NOT NULL,
   merchant_id VARCHAR(10) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
   amount INT NOT NULL,
-  attempt_status TINYINT NOT NULL COMMENT '0=CREATED',
+  hash_key_snapshot VARCHAR(64) NOT NULL COMMENT '创建时 HashKey 快照,仅用于该尝试回调验签',
+  hash_iv_snapshot VARCHAR(64) NOT NULL COMMENT '创建时 HashIV 快照,仅用于该尝试回调验签',
+  attempt_status TINYINT NOT NULL COMMENT '0=CREATED,1=PAID,2=FAILED,3=SUPERSEDED',
   active_dd_id VARCHAR(64)
     GENERATED ALWAYS AS (IF(attempt_status = 0, dd_id, NULL)) VIRTUAL,
+  trade_no VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT 'OMG 金流交易编号',
+  rtn_code INT DEFAULT NULL COMMENT 'OMG 原始交易状态码',
+  rtn_msg VARCHAR(200) DEFAULT NULL COMMENT 'OMG 原始交易讯息',
+  payment_type VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT 'OMG 回覆付款方式',
+  payment_date DATETIME DEFAULT NULL COMMENT 'OMG 付款时间',
+  trade_date DATETIME DEFAULT NULL COMMENT 'OMG 订单成立时间',
+  payment_type_charge_fee INT DEFAULT NULL COMMENT 'OMG 回传手续费',
+  simulate_paid TINYINT DEFAULT NULL COMMENT '0一般付款,1模拟付款',
+  last_notify_time DATETIME DEFAULT NULL COMMENT '最近合法付款结果通知时间',
   create_time DATETIME NOT NULL,
   update_time DATETIME NOT NULL,
   PRIMARY KEY (id),
   UNIQUE KEY uk_omg_attempt_trade_no (merchant_trade_no),
+  UNIQUE KEY uk_omg_attempt_gateway_trade_no (trade_no),
   UNIQUE KEY uk_omg_attempt_active_dd (active_dd_id),
   KEY idx_omg_attempt_dd_time (dd_id, create_time, id),
   KEY idx_omg_attempt_store_time (store_id, create_time, id)