Ver código fonte

feat(omgpay): add app return pages

qmj 2 semanas atrás
pai
commit
4c9cdadb38
19 arquivos alterados com 632 adições e 19 exclusões
  1. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyForm.java
  2. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyFormParser.java
  3. 3 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyRawBodyFilter.java
  4. 100 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentClientReturnService.java
  5. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java
  6. 82 7
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java
  7. 87 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentReturnController.java
  8. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentReturnPage.java
  9. 66 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentReturnPageRenderer.java
  10. 5 0
      ruoyi-admin/src/main/resources/application.yml
  11. 9 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgNotifyFormParserTest.java
  12. 19 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgNotifyRawBodyFilterTest.java
  13. 130 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentClientReturnServiceTest.java
  14. 29 6
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentFormFactoryTest.java
  15. 54 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentReturnControllerTest.java
  16. 11 2
      specs/016-omg-payment/contracts/api.md
  17. 8 0
      specs/016-omg-payment/plan.md
  18. 2 1
      specs/016-omg-payment/spec.md
  19. 14 0
      specs/016-omg-payment/tasks.md

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

@@ -5,7 +5,7 @@ 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. */
+/** Marks a DTO populated from a complete raw OMG server or client POST form. */
 @Target(ElementType.PARAMETER)
 @Retention(RetentionPolicy.RUNTIME)
 public @interface OmgNotifyForm {

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgNotifyFormParser.java

@@ -16,11 +16,14 @@ import java.util.Set;
 public class OmgNotifyFormParser {
     static final int MAX_FORM_BYTES = 32 * 1024;
     private static final String FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
+    private static final String OMG_DOCUMENTED_CONTENT_TYPE = "text/html";
 
     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)) {
+        String normalizedType = contentType == null ? "" : contentType.toLowerCase();
+        if (!normalizedType.startsWith(FORM_CONTENT_TYPE)
+                && !normalizedType.startsWith(OMG_DOCUMENTED_CONTENT_TYPE)) {
             return invalid(sourceIp, raw, "UNSUPPORTED_CONTENT_TYPE");
         }
         if (safeBody.length == 0) {

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

@@ -27,7 +27,9 @@ public class OmgNotifyRawBodyFilter extends OncePerRequestFilter {
     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);
+        return !"POST".equalsIgnoreCase(request.getMethod()) || !("/pay/omg/notify".equals(path)
+                || "/pay/omg/result".equals(path)
+                || "/pay/omg/payment-info-result".equals(path));
     }
 
     @Override

+ 100 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentClientReturnService.java

@@ -0,0 +1,100 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/** Verifies OMG browser-return forms and renders navigation only; it never mutates payment state. */
+@Service
+public class OmgPaymentClientReturnService {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentClientReturnService.class);
+    private static final List<String> PAYMENT_REQUIRED = List.of(
+            "MerchantID", "MerchantTradeNo", "StoreID", "RtnCode", "RtnMsg", "TradeNo",
+            "TradeAmt", "PaymentDate", "PaymentType", "PaymentTypeChargeFee", "TradeDate",
+            "SimulatePaid", "CustomField1", "CustomField2", "CustomField3", "CustomField4",
+            "CheckMacValue");
+    private static final List<String> INFO_REQUIRED = List.of(
+            "MerchantID", "MerchantTradeNo", "StoreID", "RtnCode", "RtnMsg", "TradeNo",
+            "TradeAmt", "PaymentType", "TradeDate", "CustomField1", "CustomField2",
+            "CustomField3", "CustomField4", "CheckMacValue");
+
+    private final IOmgPaymentAttemptService attempts;
+    private final OmgCheckMacSigner signer;
+    private final OmgPaymentReturnPageRenderer renderer;
+
+    public OmgPaymentClientReturnService(IOmgPaymentAttemptService attempts,
+                                         OmgCheckMacSigner signer,
+                                         OmgPaymentReturnPageRenderer renderer) {
+        this.attempts = attempts;
+        this.signer = signer;
+        this.renderer = renderer;
+    }
+
+    public OmgPaymentReturnPage paymentResult(OmgNotifyRequest request) {
+        return verifiedReturn(request, PAYMENT_REQUIRED, "payment-result");
+    }
+
+    public OmgPaymentReturnPage paymentInfoResult(OmgNotifyRequest request) {
+        return verifiedReturn(request, INFO_REQUIRED, "payment-info");
+    }
+
+    public OmgPaymentReturnPage back(String merchantTradeNo) {
+        OmgPaymentAttempt attempt = findAttempt(merchantTradeNo);
+        if (attempt == null) {
+            log.warn("OMG client back rejected merchantTradeNo={} reason=attempt_not_found",
+                    OmgPaymentCreateService.maskMerchantTradeNo(merchantTradeNo));
+            return renderer.error(404, "找不到对应的付款订单");
+        }
+        return renderer.success(attempt.getDdId(), "back");
+    }
+
+    private OmgPaymentReturnPage verifiedReturn(OmgNotifyRequest request,
+                                                List<String> requiredFields,
+                                                String source) {
+        String merchantTradeNo = request == null ? null : request.value("MerchantTradeNo");
+        OmgPaymentAttempt attempt = findAttempt(merchantTradeNo);
+        if (request == null || !request.isValid() || attempt == null
+                || !requiredFields.stream().allMatch(request::contains)
+                || !identityMatches(request, attempt) || !signatureMatches(request, attempt)) {
+            log.warn("OMG client return rejected source={}, merchantTradeNo={}, reason=trust_validation_failed",
+                    source, OmgPaymentCreateService.maskMerchantTradeNo(merchantTradeNo));
+            return renderer.error(400, "無法驗證返回資料,請回到 App 查詢訂單");
+        }
+        log.info("OMG client return accepted source={}, orderId={}, merchantTradeNo={}, rtnCode={}",
+                source, OmgPaymentController.safeLogOrderId(attempt.getDdId()),
+                OmgPaymentCreateService.maskMerchantTradeNo(merchantTradeNo), request.value("RtnCode"));
+        return renderer.success(attempt.getDdId(), source);
+    }
+
+    private boolean signatureMatches(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
+        String actual = request.value("CheckMacValue");
+        if (actual == null || !actual.matches("(?i)[0-9a-f]{64}")) {
+            return false;
+        }
+        try {
+            String expected = signer.sign(request.signingFields(),
+                    attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot());
+            return OmgPaymentNotifyService.secureEquals(expected, actual);
+        } catch (IllegalArgumentException exception) {
+            return false;
+        }
+    }
+
+    private static boolean identityMatches(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
+        return attempt.getMerchantId().equals(request.value("MerchantID"))
+                && attempt.getMerchantTradeNo().equals(request.value("MerchantTradeNo"))
+                && String.valueOf(attempt.getAmount()).equals(request.value("TradeAmt"));
+    }
+
+    private OmgPaymentAttempt findAttempt(String merchantTradeNo) {
+        if (merchantTradeNo == null || !merchantTradeNo.matches("[A-Z0-9]{1,20}")) {
+            return null;
+        }
+        return attempts.selectByMerchantTradeNo(merchantTradeNo);
+    }
+}

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java

@@ -43,6 +43,9 @@ public class OmgPaymentFormFactory {
         fields.put("TradeDesc", "Foodie order " + safeOrderId);
         fields.put("ItemName", "Order " + safeOrderId);
         fields.put("ReturnURL", properties.requireSafeReturnUrl());
+        fields.put("OrderResultURL", properties.requireSafeOrderResultUrl());
+        fields.put("ClientRedirectURL", properties.requireSafeClientRedirectUrl());
+        fields.put("ClientBackURL", properties.requireSafeClientBackUrl(merchantTradeNo));
         fields.put("ChoosePayment", "ALL");
         fields.put("EncryptType", "1");
         fields.put("InvoiceMark", "N");

+ 82 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java

@@ -9,6 +9,10 @@ import java.net.URI;
 @ConfigurationProperties(prefix = "omgpay")
 public class OmgPaymentProperties {
     private String returnUrl;
+    private String orderResultUrl;
+    private String clientRedirectUrl;
+    private String clientBackUrl;
+    private String appReturnUrl;
 
     public String getReturnUrl() {
         return returnUrl;
@@ -18,22 +22,93 @@ public class OmgPaymentProperties {
         this.returnUrl = returnUrl;
     }
 
+    public String getOrderResultUrl() {
+        return orderResultUrl;
+    }
+
+    public void setOrderResultUrl(String orderResultUrl) {
+        this.orderResultUrl = orderResultUrl;
+    }
+
+    public String getClientRedirectUrl() {
+        return clientRedirectUrl;
+    }
+
+    public void setClientRedirectUrl(String clientRedirectUrl) {
+        this.clientRedirectUrl = clientRedirectUrl;
+    }
+
+    public String getClientBackUrl() {
+        return clientBackUrl;
+    }
+
+    public void setClientBackUrl(String clientBackUrl) {
+        this.clientBackUrl = clientBackUrl;
+    }
+
+    public String getAppReturnUrl() {
+        return appReturnUrl;
+    }
+
+    public void setAppReturnUrl(String appReturnUrl) {
+        this.appReturnUrl = appReturnUrl;
+    }
+
     public String requireSafeReturnUrl() {
-        if (returnUrl == null || returnUrl.isBlank()) {
-            throw new IllegalArgumentException("OMG ReturnURL is required");
+        return requireSafeCallbackUrl(returnUrl, "/pay/omg/notify", "ReturnURL");
+    }
+
+    public String requireSafeOrderResultUrl() {
+        return requireSafeCallbackUrl(orderResultUrl, "/pay/omg/result", "OrderResultURL");
+    }
+
+    public String requireSafeClientRedirectUrl() {
+        return requireSafeCallbackUrl(clientRedirectUrl,
+                "/pay/omg/payment-info-result", "ClientRedirectURL");
+    }
+
+    public String requireSafeClientBackUrl(String merchantTradeNo) {
+        if (merchantTradeNo == null || !merchantTradeNo.matches("[A-Z0-9]{1,20}")) {
+            throw new IllegalArgumentException("OMG ClientBackURL trade number is invalid");
+        }
+        return requireSafeCallbackUrl(clientBackUrl, "/pay/omg/back", "ClientBackURL")
+                + "?merchantTradeNo=" + merchantTradeNo;
+    }
+
+    public String safeAppReturnUrlOrNull() {
+        if (appReturnUrl == null || appReturnUrl.isBlank()) {
+            return null;
+        }
+        return requireSafeHttpsUrl(appReturnUrl, "App return URL", false).toASCIIString();
+    }
+
+    private static String requireSafeCallbackUrl(String value, String expectedPath, String name) {
+        URI uri = requireSafeHttpsUrl(value, "OMG " + name, true);
+        if (!expectedPath.equals(uri.getPath())) {
+            throw new IllegalArgumentException("OMG " + name + " is unsafe");
+        }
+        return uri.toASCIIString();
+    }
+
+    private static URI requireSafeHttpsUrl(String value, String name, boolean rejectRootPath) {
+        if (value == null || value.isBlank()) {
+            throw new IllegalArgumentException(name + " is required");
         }
         URI uri;
         try {
-            uri = URI.create(returnUrl.trim());
+            uri = URI.create(value.trim());
         } catch (IllegalArgumentException error) {
-            throw new IllegalArgumentException("OMG ReturnURL is invalid", error);
+            throw new IllegalArgumentException(name + " is invalid", error);
+        }
+        if (value.trim().length() > 200) {
+            throw new IllegalArgumentException(name + " exceeds 200 characters");
         }
         if (!uri.isAbsolute() || !"https".equalsIgnoreCase(uri.getScheme())
                 || uri.getHost() == null || uri.getHost().isBlank()
                 || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null
-                || !"/pay/omg/notify".equals(uri.getPath())) {
-            throw new IllegalArgumentException("OMG ReturnURL is unsafe");
+                || (rejectRootPath && (uri.getPath() == null || "/".equals(uri.getPath())))) {
+            throw new IllegalArgumentException(name + " is unsafe");
         }
-        return uri.toASCIIString();
+        return uri;
     }
 }

+ 87 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentReturnController.java

@@ -0,0 +1,87 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.system.omgpay.service.OmgIpnAuditService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.nio.charset.StandardCharsets;
+
+/** Browser-facing OMG return endpoints; payment state remains owned by notify/query processing. */
+@RestController
+@RequestMapping("/pay/omg")
+public class OmgPaymentReturnController {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentReturnController.class);
+    private final OmgPaymentClientReturnService service;
+    private final OmgIpnAuditService ipnAuditService;
+
+    public OmgPaymentReturnController(OmgPaymentClientReturnService service,
+                                      OmgIpnAuditService ipnAuditService) {
+        this.service = service;
+        this.ipnAuditService = ipnAuditService;
+    }
+
+    @Anonymous
+    @PostMapping(value = "/result", produces = MediaType.TEXT_HTML_VALUE)
+    public ResponseEntity<String> result(@OmgNotifyForm OmgNotifyRequest request) {
+        log.info("OMG client payment result received ip={}, rawForm={}",
+                sourceIp(request), rawForm(request));
+        appendAudit(request);
+        return html(service.paymentResult(request));
+    }
+
+    @Anonymous
+    @PostMapping(value = "/payment-info-result", produces = MediaType.TEXT_HTML_VALUE)
+    public ResponseEntity<String> paymentInfoResult(@OmgNotifyForm OmgNotifyRequest request) {
+        log.info("OMG client payment-info result received ip={}, rawForm={}",
+                sourceIp(request), rawForm(request));
+        appendAudit(request);
+        return html(service.paymentInfoResult(request));
+    }
+
+    @Anonymous
+    @GetMapping(value = "/back", produces = MediaType.TEXT_HTML_VALUE)
+    public ResponseEntity<String> back(@RequestParam(name = "merchantTradeNo") String merchantTradeNo) {
+        log.info("OMG client back received merchantTradeNo={}",
+                OmgPaymentCreateService.maskMerchantTradeNo(merchantTradeNo));
+        return html(service.back(merchantTradeNo));
+    }
+
+    private static String sourceIp(OmgNotifyRequest request) {
+        return request == null ? "unknown" : request.getSourceIp();
+    }
+
+    private static String rawForm(OmgNotifyRequest request) {
+        return request == null ? "" : request.getRawForm();
+    }
+
+    private void appendAudit(OmgNotifyRequest request) {
+        try {
+            ipnAuditService.append(sourceIp(request), rawForm(request));
+        } catch (Exception auditError) {
+            log.error("OMG client return IPN append failed ip={}, rawForm={}",
+                    sourceIp(request), rawForm(request), auditError);
+        }
+    }
+
+    private static ResponseEntity<String> html(OmgPaymentReturnPage page) {
+        return ResponseEntity.status(page.statusCode())
+                .contentType(new MediaType("text", "html", StandardCharsets.UTF_8))
+                .header(HttpHeaders.CACHE_CONTROL, "no-store")
+                .header("Pragma", "no-cache")
+                .header("Referrer-Policy", "no-referrer")
+                .header("X-Content-Type-Options", "nosniff")
+                .header("Content-Security-Policy",
+                        "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'")
+                .body(page.html());
+    }
+}

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

@@ -0,0 +1,5 @@
+package com.ruoyi.app.omgpay;
+
+/** HTTP status and no-store HTML rendered for one OMG browser return. */
+public record OmgPaymentReturnPage(int statusCode, String html) {
+}

+ 66 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentReturnPageRenderer.java

@@ -0,0 +1,66 @@
+package com.ruoyi.app.omgpay;
+
+import org.springframework.stereotype.Component;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/** Renders a fixed, escaped page that attempts an HTTPS App Link and keeps a manual fallback button. */
+@Component
+public class OmgPaymentReturnPageRenderer {
+    private final OmgPaymentProperties properties;
+
+    public OmgPaymentReturnPageRenderer(OmgPaymentProperties properties) {
+        this.properties = properties;
+    }
+
+    public OmgPaymentReturnPage success(String orderId, String source) {
+        String appLink = buildAppLink(orderId, source);
+        if (appLink == null) {
+            return error(503, "App 返回地址尚未配置");
+        }
+        String escapedLink = escapeHtml(appLink);
+        String html = page("正在返回 App", "请稍候,App 打开后会确认最终付款状态。",
+                "<a id=\"open-app\" href=\"" + escapedLink + "\">返回 App</a>"
+                        + "<script>window.location.replace(document.getElementById('open-app').href);</script>");
+        return new OmgPaymentReturnPage(200, html);
+    }
+
+    public OmgPaymentReturnPage error(int statusCode, String message) {
+        return new OmgPaymentReturnPage(statusCode,
+                page("无法返回 App", escapeHtml(message), ""));
+    }
+
+    private String buildAppLink(String orderId, String source) {
+        String base = properties.safeAppReturnUrlOrNull();
+        if (base == null) {
+            return null;
+        }
+        String separator = base.contains("?") ? "&" : "?";
+        return base + separator + "orderId=" + encode(orderId) + "&source=" + encode(source);
+    }
+
+    private static String page(String title, String message, String action) {
+        return "<!doctype html><html lang=\"zh-Hant\"><head><meta charset=\"utf-8\">"
+                + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+                + "<title>" + title + "</title><style>body{font-family:system-ui,sans-serif;"
+                + "margin:0;padding:32px;background:#f7f7f7;color:#222;text-align:center}main{max-width:480px;"
+                + "margin:15vh auto;background:#fff;padding:32px;border-radius:16px}a{display:inline-block;"
+                + "margin-top:20px;padding:12px 24px;background:#1677ff;color:#fff;text-decoration:none;"
+                + "border-radius:8px}</style></head><body><main><h1>" + title + "</h1><p>"
+                + message + "</p>" + action + "</main></body></html>";
+    }
+
+    private static String encode(String value) {
+        return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8)
+                .replace("+", "%20");
+    }
+
+    private static String escapeHtml(String value) {
+        if (value == null) {
+            return "";
+        }
+        return value.replace("&", "&amp;").replace("<", "&lt;")
+                .replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;");
+    }
+}

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

@@ -44,6 +44,11 @@ omg:
 
 omgpay:
   return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify
+  order-result-url: https://foodieapi.waimai-paotui.com/pay/omg/result
+  client-redirect-url: https://foodieapi.waimai-paotui.com/pay/omg/payment-info-result
+  client-back-url: https://foodieapi.waimai-paotui.com/pay/omg/back
+  # 必须配置为已完成 iOS Universal Link / Android App Link 关联的 HTTPS 地址。
+  app-return-url: ${OMG_APP_RETURN_URL:}
   reconcile:
     # 自动补偿只扫描到期的 CREATED 尝试;多实例由分布式锁保证单轮仅一个节点执行。
     fixed-delay-ms: 60000

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

@@ -47,4 +47,13 @@ class OmgNotifyFormParserTest {
         assertFalse(wrongType.isValid());
         assertFalse(tooLarge.isValid());
     }
+
+    @Test
+    void acceptsOmgDocumentedTextHtmlClientPostBody() {
+        OmgNotifyRequest request = parser.parse("RtnCode=1&CustomField1=".getBytes(StandardCharsets.UTF_8),
+                "127.0.0.1", "text/html; charset=UTF-8");
+
+        assertTrue(request.isValid());
+        assertEquals("1", request.value("RtnCode"));
+    }
 }

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

@@ -9,6 +9,7 @@ import org.springframework.mock.web.MockHttpServletResponse;
 import java.nio.charset.StandardCharsets;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
 
 class OmgNotifyRawBodyFilterTest {
 
@@ -25,4 +26,22 @@ class OmgNotifyRawBodyFilterTest {
         ServletRequest wrapped = chain.getRequest();
         assertEquals(raw, new String(wrapped.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
     }
+
+    @Test
+    void preservesBothClientPostBodiesButDoesNotWrapUnrelatedRequests() throws Exception {
+        OmgNotifyRawBodyFilter filter = new OmgNotifyRawBodyFilter();
+        for (String path : new String[]{"/pay/omg/result", "/pay/omg/payment-info-result"}) {
+            MockHttpServletRequest request = new MockHttpServletRequest("POST", path);
+            request.setContent("MerchantTradeNo=FULL".getBytes(StandardCharsets.UTF_8));
+            MockFilterChain chain = new MockFilterChain();
+            filter.doFilter(request, new MockHttpServletResponse(), chain);
+            assertEquals("MerchantTradeNo=FULL", new String(
+                    chain.getRequest().getInputStream().readAllBytes(), StandardCharsets.UTF_8));
+        }
+
+        MockHttpServletRequest unrelated = new MockHttpServletRequest("POST", "/pay/omg/query");
+        MockFilterChain chain = new MockFilterChain();
+        filter.doFilter(unrelated, new MockHttpServletResponse(), chain);
+        assertSame(unrelated, chain.getRequest());
+    }
 }

+ 130 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentClientReturnServiceTest.java

@@ -0,0 +1,130 @@
+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.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.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OmgPaymentClientReturnServiceTest {
+    private static final String HASH_KEY = "5294y06JbISpM5x9";
+    private static final String HASH_IV = "v77hoKGq4kWxNNIS";
+
+    private IOmgPaymentAttemptService attempts;
+    private OmgPaymentClientReturnService service;
+
+    @BeforeEach
+    void setUp() {
+        attempts = mock(IOmgPaymentAttemptService.class);
+        OmgPaymentProperties properties = new OmgPaymentProperties();
+        properties.setAppReturnUrl("https://link.waimai-paotui.com/payment/omg");
+        service = new OmgPaymentClientReturnService(attempts, new OmgCheckMacSigner(),
+                new OmgPaymentReturnPageRenderer(properties));
+        OmgPaymentAttempt attempt = new OmgPaymentAttempt();
+        attempt.setId(7L);
+        attempt.setDdId("DD-1&next=evil");
+        attempt.setMerchantId("1000031");
+        attempt.setMerchantTradeNo("OMG123");
+        attempt.setAmount(100);
+        attempt.setHashKeySnapshot(HASH_KEY);
+        attempt.setHashIvSnapshot(HASH_IV);
+        when(attempts.selectByMerchantTradeNo("OMG123")).thenReturn(attempt);
+    }
+
+    @Test
+    void verifiedPaymentResultReturnsNoStoreHtmlThatOnlyNavigatesToApp() {
+        LinkedHashMap<String, String> fields = paymentFields();
+        fields.put("FutureExtraField", "signed extra");
+        OmgPaymentReturnPage page = service.paymentResult(signedRequest(fields));
+
+        assertEquals(200, page.statusCode());
+        assertTrue(page.html().contains("https://link.waimai-paotui.com/payment/omg?orderId=DD-1%26next%3Devil&amp;source=payment-result"));
+        assertTrue(page.html().contains("正在返回 App"));
+        assertFalse(page.html().contains(HASH_KEY));
+        assertFalse(page.html().contains("CheckMacValue"));
+    }
+
+    @Test
+    void tamperingAnyExtraFieldRejectsTheWholeClientResult() {
+        LinkedHashMap<String, String> fields = paymentFields();
+        fields.put("FutureExtraField", "signed extra");
+        OmgNotifyRequest signed = signedRequest(fields);
+        List<OmgNotifyField> tamperedFields = new ArrayList<>(signed.getFields());
+        tamperedFields.replaceAll(field -> "FutureExtraField".equals(field.name())
+                ? new OmgNotifyField(field.name(), "tampered") : field);
+
+        OmgPaymentReturnPage page = service.paymentResult(
+                new OmgNotifyRequest("127.0.0.1", "tampered", tamperedFields, null));
+
+        assertEquals(400, page.statusCode());
+        assertFalse(page.html().contains("orderId="));
+        assertTrue(page.html().contains("無法驗證返回資料"));
+    }
+
+    @Test
+    void verifiedPaymentInfoAndPlainBackUseDifferentNavigationSources() {
+        LinkedHashMap<String, String> info = commonFields("2", "ATM_TAISHIN");
+        info.put("BankCode", "812");
+        info.put("vAccount", "1234567890123456");
+        info.put("ExpireDate", "2026/08/14 23:59:59");
+
+        OmgPaymentReturnPage paymentInfo = service.paymentInfoResult(signedRequest(info));
+        OmgPaymentReturnPage back = service.back("OMG123");
+
+        assertEquals(200, paymentInfo.statusCode());
+        assertTrue(paymentInfo.html().contains("source=payment-info"));
+        assertTrue(back.html().contains("source=back"));
+        assertFalse(back.html().contains("付款成功"));
+    }
+
+    @Test
+    void unknownBackReferenceDoesNotCreateAnAppLink() {
+        OmgPaymentReturnPage page = service.back("UNKNOWN");
+
+        assertEquals(404, page.statusCode());
+        assertFalse(page.html().contains("orderId="));
+    }
+
+    private OmgNotifyRequest signedRequest(LinkedHashMap<String, String> fields) {
+        fields.put("CheckMacValue", new OmgCheckMacSigner().sign(fields, HASH_KEY, HASH_IV));
+        List<OmgNotifyField> result = fields.entrySet().stream()
+                .map(entry -> new OmgNotifyField(entry.getKey(), entry.getValue())).toList();
+        return new OmgNotifyRequest("127.0.0.1", "fixture", result, null);
+    }
+
+    private static LinkedHashMap<String, String> paymentFields() {
+        LinkedHashMap<String, String> fields = commonFields("1", "Credit_CreditCard");
+        fields.put("PaymentDate", "2026/08/13 16:00:00");
+        fields.put("PaymentTypeChargeFee", "0.00");
+        fields.put("SimulatePaid", "0");
+        return fields;
+    }
+
+    private static LinkedHashMap<String, String> commonFields(String rtnCode, String paymentType) {
+        LinkedHashMap<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", "1000031");
+        fields.put("MerchantTradeNo", "OMG123");
+        fields.put("StoreID", "");
+        fields.put("RtnCode", rtnCode);
+        fields.put("RtnMsg", "Succeeded");
+        fields.put("TradeNo", "GW123");
+        fields.put("TradeAmt", "100");
+        fields.put("PaymentType", paymentType);
+        fields.put("TradeDate", "2026/08/13 15:59:00");
+        fields.put("CustomField1", "");
+        fields.put("CustomField2", "");
+        fields.put("CustomField3", "");
+        fields.put("CustomField4", "");
+        return fields;
+    }
+}

+ 29 - 6
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentFormFactoryTest.java

@@ -23,7 +23,7 @@ import static org.mockito.Mockito.when;
 class OmgPaymentFormFactoryTest {
     @Test
     void buildsExactStageOnlySignedForm() {
-        OmgPaymentProperties properties = properties("https://foodieapi.waimai-paotui.com/pay/omg/notify");
+        OmgPaymentProperties properties = properties();
         OmgCheckMacSigner signer = mock(OmgCheckMacSigner.class);
         when(signer.sign(anyMap(), eq("KEY"), eq("IV"))).thenReturn("A".repeat(64));
         OmgPaymentFormFactory factory = new OmgPaymentFormFactory(properties, signer,
@@ -35,14 +35,21 @@ class OmgPaymentFormFactoryTest {
         assertEquals(Set.of("MerchantID", "MerchantTradeNo", "MerchantTradeDate", "PaymentType",
                 "TotalAmount", "TradeDesc", "ItemName", "ReturnURL", "ChoosePayment", "EncryptType",
                 "InvoiceMark", "NeedExtraPaidInfo", "ExpireDate", "StoreExpireDate",
-                "BarcodeATMExpireDate", "CheckMacValue"), form.fields().keySet());
+                "BarcodeATMExpireDate", "OrderResultURL", "ClientRedirectURL", "ClientBackURL",
+                "CheckMacValue"), form.fields().keySet());
         assertEquals("2026/08/13 15:30:23", form.fields().get("MerchantTradeDate"));
         assertEquals("ALL", form.fields().get("ChoosePayment"));
         assertEquals("Y", form.fields().get("NeedExtraPaidInfo"));
+        assertEquals("https://foodieapi.waimai-paotui.com/pay/omg/result",
+                form.fields().get("OrderResultURL"));
+        assertEquals("https://foodieapi.waimai-paotui.com/pay/omg/payment-info-result",
+                form.fields().get("ClientRedirectURL"));
+        assertEquals("https://foodieapi.waimai-paotui.com/pay/omg/back?merchantTradeNo=OMG12345678901234567",
+                form.fields().get("ClientBackURL"));
         assertEquals("Foodie order DDscript", form.fields().get("TradeDesc"));
         assertFalse(form.fields().get("ItemName").contains("|"));
         assertTrue(form.fields().get("ItemName").length() <= 120);
-        verify(signer).sign(org.mockito.ArgumentMatchers.argThat(fields -> fields.size() == 15
+        verify(signer).sign(org.mockito.ArgumentMatchers.argThat(fields -> fields.size() == 18
                 && !fields.containsKey("CheckMacValue")), eq("KEY"), eq("IV"));
         assertThrows(UnsupportedOperationException.class, () -> form.fields().put("extra", "value"));
     }
@@ -52,12 +59,28 @@ class OmgPaymentFormFactoryTest {
             "https://example.com/pay/omg/notify?q=1", "https://example.com/pay/omg/notify#x",
             "https://user@example.com/pay/omg/notify", "/pay/omg/notify"})
     void rejectsUnsafeReturnUrls(String returnUrl) {
-        assertThrows(IllegalArgumentException.class, () -> properties(returnUrl).requireSafeReturnUrl());
+        OmgPaymentProperties properties = properties();
+        properties.setReturnUrl(returnUrl);
+        assertThrows(IllegalArgumentException.class, properties::requireSafeReturnUrl);
     }
 
-    private static OmgPaymentProperties properties(String returnUrl) {
+    @Test
+    void rejectsUnsafeClientAndAppReturnUrls() {
+        OmgPaymentProperties properties = properties();
+        properties.setOrderResultUrl("https://example.com/wrong");
+        assertThrows(IllegalArgumentException.class, properties::requireSafeOrderResultUrl);
+        properties.setOrderResultUrl("https://foodieapi.waimai-paotui.com/pay/omg/result");
+        properties.setAppReturnUrl("javascript:alert(1)");
+        assertThrows(IllegalArgumentException.class, properties::safeAppReturnUrlOrNull);
+    }
+
+    private static OmgPaymentProperties properties() {
         OmgPaymentProperties properties = new OmgPaymentProperties();
-        properties.setReturnUrl(returnUrl);
+        properties.setReturnUrl("https://foodieapi.waimai-paotui.com/pay/omg/notify");
+        properties.setOrderResultUrl("https://foodieapi.waimai-paotui.com/pay/omg/result");
+        properties.setClientRedirectUrl("https://foodieapi.waimai-paotui.com/pay/omg/payment-info-result");
+        properties.setClientBackUrl("https://foodieapi.waimai-paotui.com/pay/omg/back");
+        properties.setAppReturnUrl("https://link.waimai-paotui.com/payment/omg");
         return properties;
     }
 }

+ 54 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentReturnControllerTest.java

@@ -0,0 +1,54 @@
+package com.ruoyi.app.omgpay;
+
+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.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import java.lang.reflect.Method;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OmgPaymentReturnControllerTest {
+
+    @Test
+    void exposesThreeAnonymousExplicitDtoContracts() throws Exception {
+        Method result = OmgPaymentReturnController.class.getDeclaredMethod("result", OmgNotifyRequest.class);
+        Method info = OmgPaymentReturnController.class.getDeclaredMethod(
+                "paymentInfoResult", OmgNotifyRequest.class);
+        Method back = OmgPaymentReturnController.class.getDeclaredMethod("back", String.class);
+
+        assertArrayEquals(new String[]{"/result"}, result.getAnnotation(PostMapping.class).value());
+        assertArrayEquals(new String[]{"/payment-info-result"}, info.getAnnotation(PostMapping.class).value());
+        assertArrayEquals(new String[]{"/back"}, back.getAnnotation(GetMapping.class).value());
+        assertNotNull(result.getAnnotation(Anonymous.class));
+        assertNotNull(info.getAnnotation(Anonymous.class));
+        assertNotNull(back.getAnnotation(Anonymous.class));
+        assertNotNull(result.getParameters()[0].getAnnotation(OmgNotifyForm.class));
+        assertEquals("merchantTradeNo", back.getParameters()[0].getAnnotation(RequestParam.class).name());
+    }
+
+    @Test
+    void returnsSafeNonCachedHtmlHeadersAndServiceStatus() {
+        OmgPaymentClientReturnService service = mock(OmgPaymentClientReturnService.class);
+        OmgIpnAuditService audit = mock(OmgIpnAuditService.class);
+        OmgNotifyRequest request = new OmgNotifyRequest("127.0.0.1", "raw", List.of(), null);
+        when(service.paymentResult(request)).thenReturn(new OmgPaymentReturnPage(400, "<html>safe</html>"));
+
+        ResponseEntity<String> response = new OmgPaymentReturnController(service, audit).result(request);
+
+        assertEquals(400, response.getStatusCode().value());
+        assertEquals("<html>safe</html>", response.getBody());
+        assertEquals("no-store", response.getHeaders().getFirst("Cache-Control"));
+        assertEquals("no-referrer", response.getHeaders().getFirst("Referrer-Policy"));
+        assertTrue(response.getHeaders().getFirst("Content-Security-Policy").contains("default-src 'none'"));
+        org.mockito.Mockito.verify(audit).append("127.0.0.1", "raw");
+    }
+}

+ 11 - 2
specs/016-omg-payment/contracts/api.md

@@ -58,9 +58,18 @@
 
 - 处理 A2:collectForm → IpnLog → 凭证(MerchantID) → 验签 CheckMacValue → 幂等(trade_no) → 金额校验 → RtnCode==1 && SimulatePaid!=1 → markSuccess → 更新订单(state=0, payStatus=1) + 订单日志 + 推送用户/商家/骑手 + sendAcceptRiderPush → 回纯串 `1|OK`
 
-### B3. 支付完成返回页 — `GET|POST /pay/omg/return`(US2,@Anonymous)
+### B3. ??????? ? `POST /pay/omg/result`?US2?@Anonymous?
 
-- 仅引导回前端结果页(带 ddId),**不改订单状态**(以 notify 为准)
+- ?? `OrderResultURL` ?? OMG Client POST????? DTO ?????????????????????????????? CheckMacValue?
+- ???????? no-store HTML?????? HTTPS Universal/App Link ?? App?????????App ???? `/pay/omg/query`?
+
+### B3a. ??????? ? `POST /pay/omg/payment-info-result`?US3?@Anonymous?
+
+- ?? `ClientRedirectURL` ?? ATM/CVS/BarcodeATM Client POST??????????????? App ?????????????
+
+### B3b. ????? ? `GET /pay/omg/back?merchantTradeNo=`?@Anonymous?
+
+- ?? `ClientBackURL`???? MerchantTradeNo ????????????? App?OMG ????????????????????????
 
 ### B4. ATM/超商 取号回调 — `POST /pay/omg/paymentInfo`(US3,@Anonymous)
 

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

@@ -108,6 +108,14 @@ application.yml (+application-dev.yml) # 新增 omg.* 配置段
 - 复用已经完成的查询响应验签和支付状态机:已付款补成已付款,明确失败同步失败,未付款或暂时异常保留并等待下一轮。
 - 单轮限制 45 秒预算,单条异常记录完整异常上下文并继续,不记录 HashKey/HashIV。
 
+## 2026-08-13 App ???????
+
+- ?????????? `OrderResultURL`?`ClientRedirectURL`?`ClientBackURL`???????????????????
+- `POST /pay/omg/result` ?????? Client POST?`POST /pay/omg/payment-info-result` ???????? Client POST?????????????????????????????????? CheckMacValue?
+- `GET /pay/omg/back` ??????? MerchantTradeNo ????????????? no-store HTML????????????
+- HTML ????????? HTTPS Universal Link/App Link?????? URL ? HTML ??????????????App ????? `/pay/omg/query` ?????
+- `OMG_APP_RETURN_URL` ?????????????????????????????? App scheme ?????
+
 ## Complexity Tracking
 
 > Constitution Check 无违规,无需填表。

+ 2 - 1
specs/016-omg-payment/spec.md

@@ -158,6 +158,8 @@ OMG 服务端回调(`ReturnURL`)到达后,验签 + 幂等更新订单,
 
 - **FR-017**:系统 MUST 自动补偿仍为 `CREATED` 的当前有效支付尝试。调度器按 `next_query_time` 取有限批次,并在真实查询前用条件更新预留该行;查询确认已付款或明确失败时必须复用主动查询的验签与同一状态机,未付款或暂时异常不得关闭支付尝试。
 
+- **FR-018**??? MUST ? `OrderResultURL`?`ClientRedirectURL`?`ClientBackURL` ??? `omgpay` ????????????????? HTML ??????? HTTPS Universal Link/App Link ?? App?`OrderResultURL` ? `ClientRedirectURL` ?????????????????????????? CheckMacValue ?????????????????????????App ??????? `/pay/omg/query` ???????
+
 ### Key Entities *(include if feature involves data)*
 
 - **门店 OMG 凭证(StoreOmgCredential)**:门店关联、MerchantID、HashKey、HashIV、环境(测试/生产)、启用开关。
@@ -184,4 +186,3 @@ OMG 服务端回调(`ReturnURL`)到达后,验签 + 幂等更新订单,
 - 测试先用 `payment-stage.funpoint.com.tw` 测试端点与测试凭证,生产环境与凭证后续切换。
 - 凭证签名等敏感操作在服务端完成,HashKey/HashIV 不下发前端。
 - 在线支付与现有「货到付款」并存,由门店配置决定是否提供在线支付。
-

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

@@ -209,6 +209,20 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 ---
 
+## Phase 14: OMG ?????? App ???Priority: P1?
+
+- [x] T081 ?????? `OrderResultURL`?`ClientRedirectURL`?`ClientBackURL`?????? App Link ??? `omgpay` ????? HTTPS/path ?????
+- [x] T082 ?? Client POST ????????????? DTO?????????????? CheckMacValue???????????
+- [x] T083 ?? `/pay/omg/result`?`/pay/omg/payment-info-result`?`/pay/omg/back` ???? no-store/CSP HTML ????????????????
+- [x] T084 ??? Client POST ????????? `ipn_log` ???????????HashKey/HashIV ?????????
+- [x] T085 ?????????????????????????????HTML ?????????????
+- [ ] T086 ?? `OMG_APP_RETURN_URL` ??? iOS Universal Link / Android App Link ? App ????App ????? `/pay/omg/query`??? App ??? bundle/package ?????????????????
+- [ ] T087 ?? OMG ??????????? Maven ???????? stage ???????????????
+
+**Checkpoint**??????????????????? `OMG_APP_RETURN_URL`???????????????????
+
+---
+
 ## Dependencies & Execution Order
 
 ### Phase 依赖