Procházet zdrojové kódy

feat(omgpay): return payment result to app success page

qmj před 2 týdny
rodič
revize
a71be28127

+ 7 - 18
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentClientReturnService.java

@@ -31,35 +31,24 @@ public class OmgPaymentClientReturnService {
     }
 
     public OmgPaymentReturnPage paymentResult(OmgNotifyRequest request) {
-        return verifiedReturn(request, PAYMENT_REQUIRED, "payment-result");
-    }
-
-    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");
+        return verifiedReturn(request, PAYMENT_REQUIRED);
     }
 
     private OmgPaymentReturnPage verifiedReturn(OmgNotifyRequest request,
-                                                List<String> requiredFields,
-                                                String source) {
+                                                List<String> requiredFields) {
         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));
+            log.warn("OMG client return rejected source=payment-result, merchantTradeNo={}, reason=trust_validation_failed",
+                    OmgPaymentCreateService.maskMerchantTradeNo(merchantTradeNo));
             return renderer.error(400, "無法驗證返回資料,請回到 App 查詢訂單");
         }
-        log.info("OMG client return accepted source={}, orderId={}, merchantTradeNo={}, rtnCode={}",
-                source, OmgPaymentController.safeLogOrderId(attempt.getDdId()),
+        log.info("OMG client return accepted source=payment-result, orderId={}, merchantTradeNo={}, rtnCode={}",
+                OmgPaymentController.safeLogOrderId(attempt.getDdId()),
                 OmgPaymentCreateService.maskMerchantTradeNo(merchantTradeNo), request.value("RtnCode"));
-        return renderer.success(attempt.getDdId(), source);
+        return renderer.success(attempt.getDdId());
     }
 
     private boolean signatureMatches(OmgNotifyRequest request, OmgPaymentAttempt attempt) {

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

@@ -44,7 +44,6 @@ public class OmgPaymentFormFactory {
         fields.put("ItemName", "Order " + safeOrderId);
         fields.put("ReturnURL", properties.requireSafeReturnUrl());
         fields.put("OrderResultURL", properties.requireSafeOrderResultUrl());
-        fields.put("ClientBackURL", properties.requireSafeClientBackUrl(merchantTradeNo));
         // OMG documents Apple Pay under Credit. UnionPay=2 hides the remaining UnionPay option.
         fields.put("ChoosePayment", "Credit");
         fields.put("UnionPay", "2");

+ 16 - 18
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java

@@ -8,9 +8,11 @@ import java.net.URI;
 @Component
 @ConfigurationProperties(prefix = "omgpay")
 public class OmgPaymentProperties {
+    private static final String APP_RETURN_SCHEME = "com.twanmsdyh.app";
+    private static final String APP_RETURN_HOST = "pages";
+    private static final String APP_RETURN_PATH = "/OrderList/paySuccess/paySuccess";
     private String returnUrl;
     private String orderResultUrl;
-    private String clientBackUrl;
     private String appReturnUrl;
 
     public String getReturnUrl() {
@@ -29,14 +31,6 @@ public class OmgPaymentProperties {
         this.orderResultUrl = orderResultUrl;
     }
 
-    public String getClientBackUrl() {
-        return clientBackUrl;
-    }
-
-    public void setClientBackUrl(String clientBackUrl) {
-        this.clientBackUrl = clientBackUrl;
-    }
-
     public String getAppReturnUrl() {
         return appReturnUrl;
     }
@@ -53,19 +47,23 @@ public class OmgPaymentProperties {
         return requireSafeCallbackUrl(orderResultUrl, "/pay/omg/result", "OrderResultURL");
     }
 
-    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();
+        URI uri;
+        try {
+            uri = URI.create(appReturnUrl.trim());
+        } catch (IllegalArgumentException error) {
+            throw new IllegalArgumentException("App return URL is invalid", error);
+        }
+        if (!APP_RETURN_SCHEME.equalsIgnoreCase(uri.getScheme())
+                || !APP_RETURN_HOST.equals(uri.getHost())
+                || !APP_RETURN_PATH.equals(uri.getPath())
+                || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null) {
+            throw new IllegalArgumentException("App return URL is unsafe");
+        }
+        return uri.toASCIIString();
     }
 
     private static String requireSafeCallbackUrl(String value, String expectedPath, String name) {

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

@@ -8,10 +8,8 @@ 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;
@@ -39,14 +37,6 @@ public class OmgPaymentReturnController {
         return html(service.paymentResult(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();
     }

+ 5 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentReturnPageRenderer.java

@@ -5,7 +5,7 @@ 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. */
+/** Renders a fixed, escaped page that opens the configured App route and keeps a manual fallback button. */
 @Component
 public class OmgPaymentReturnPageRenderer {
     private final OmgPaymentProperties properties;
@@ -14,8 +14,8 @@ public class OmgPaymentReturnPageRenderer {
         this.properties = properties;
     }
 
-    public OmgPaymentReturnPage success(String orderId, String source) {
-        String appLink = buildAppLink(orderId, source);
+    public OmgPaymentReturnPage success(String orderId) {
+        String appLink = buildAppLink(orderId);
         if (appLink == null) {
             return error(503, "App 返回地址尚未配置");
         }
@@ -31,13 +31,12 @@ public class OmgPaymentReturnPageRenderer {
                 page("无法返回 App", escapeHtml(message), ""));
     }
 
-    private String buildAppLink(String orderId, String source) {
+    private String buildAppLink(String orderId) {
         String base = properties.safeAppReturnUrlOrNull();
         if (base == null) {
             return null;
         }
-        String separator = base.contains("?") ? "&" : "?";
-        return base + separator + "orderId=" + encode(orderId) + "&source=" + encode(source);
+        return base + "?ddId=" + encode(orderId);
     }
 
     private static String page(String title, String message, String action) {

+ 2 - 3
ruoyi-admin/src/main/resources/application.yml

@@ -45,9 +45,8 @@ omg:
 omgpay:
   return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify
   order-result-url: https://foodieapi.waimai-paotui.com/pay/omg/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:}
+  # 支付结果页固定跳回 App 支付成功页面,并由后端追加 ddId。
+  app-return-url: com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess
   reconcile:
     # 自动补偿只扫描到期的 CREATED 尝试;多实例由分布式锁保证单轮仅一个节点执行。
     fixed-delay-ms: 60000

+ 5 - 19
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentClientReturnServiceTest.java

@@ -27,7 +27,7 @@ class OmgPaymentClientReturnServiceTest {
     void setUp() {
         attempts = mock(IOmgPaymentAttemptService.class);
         OmgPaymentProperties properties = new OmgPaymentProperties();
-        properties.setAppReturnUrl("https://link.waimai-paotui.com/payment/omg");
+        properties.setAppReturnUrl("com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess");
         service = new OmgPaymentClientReturnService(attempts, new OmgCheckMacSigner(),
                 new OmgPaymentReturnPageRenderer(properties));
         OmgPaymentAttempt attempt = new OmgPaymentAttempt();
@@ -48,7 +48,9 @@ class OmgPaymentClientReturnServiceTest {
         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(
+                "com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId=DD-1%26next%3Devil"));
+        assertFalse(page.html().contains("source="));
         assertTrue(page.html().contains("正在返回 App"));
         assertFalse(page.html().contains(HASH_KEY));
         assertFalse(page.html().contains("CheckMacValue"));
@@ -67,26 +69,10 @@ class OmgPaymentClientReturnServiceTest {
                 new OmgNotifyRequest("127.0.0.1", "tampered", tamperedFields, null));
 
         assertEquals(400, page.statusCode());
-        assertFalse(page.html().contains("orderId="));
+        assertFalse(page.html().contains("ddId="));
         assertTrue(page.html().contains("無法驗證返回資料"));
     }
 
-    @Test
-    void plainBackUsesItsOwnNavigationSource() {
-        OmgPaymentReturnPage back = service.back("OMG123");
-
-        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()

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

@@ -34,7 +34,7 @@ class OmgPaymentFormFactoryTest {
         assertEquals(OmgPaymentFormFactory.STAGE_GATEWAY_URL, form.gatewayUrl());
         assertEquals(Set.of("MerchantID", "MerchantTradeNo", "MerchantTradeDate", "PaymentType",
                 "TotalAmount", "TradeDesc", "ItemName", "ReturnURL", "ChoosePayment", "EncryptType",
-                "InvoiceMark", "NeedExtraPaidInfo", "UnionPay", "OrderResultURL", "ClientBackURL",
+                "InvoiceMark", "NeedExtraPaidInfo", "UnionPay", "OrderResultURL",
                 "CheckMacValue"), form.fields().keySet());
         assertEquals("2026/08/13 15:30:23", form.fields().get("MerchantTradeDate"));
         assertEquals("Credit", form.fields().get("ChoosePayment"));
@@ -42,8 +42,7 @@ class OmgPaymentFormFactoryTest {
         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/back?merchantTradeNo=OMG12345678901234567",
-                form.fields().get("ClientBackURL"));
+        assertFalse(form.fields().containsKey("ClientBackURL"));
         assertFalse(form.fields().containsKey("ClientRedirectURL"));
         assertFalse(form.fields().containsKey("ExpireDate"));
         assertFalse(form.fields().containsKey("StoreExpireDate"));
@@ -51,7 +50,7 @@ class OmgPaymentFormFactoryTest {
         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() == 14
                 && "Credit".equals(fields.get("ChoosePayment"))
                 && "2".equals(fields.get("UnionPay"))
                 && !fields.containsKey("CheckMacValue")), eq("KEY"), eq("IV"));
@@ -69,12 +68,12 @@ class OmgPaymentFormFactoryTest {
     }
 
     @Test
-    void rejectsUnsafeClientAndAppReturnUrls() {
+    void rejectsUnsafeResultAndAppReturnUrls() {
         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)");
+        properties.setAppReturnUrl("com.twanmsdyh.app://pages/Other/page");
         assertThrows(IllegalArgumentException.class, properties::safeAppReturnUrlOrNull);
     }
 
@@ -82,8 +81,7 @@ class OmgPaymentFormFactoryTest {
         OmgPaymentProperties properties = new OmgPaymentProperties();
         properties.setReturnUrl("https://foodieapi.waimai-paotui.com/pay/omg/notify");
         properties.setOrderResultUrl("https://foodieapi.waimai-paotui.com/pay/omg/result");
-        properties.setClientBackUrl("https://foodieapi.waimai-paotui.com/pay/omg/back");
-        properties.setAppReturnUrl("https://link.waimai-paotui.com/payment/omg");
+        properties.setAppReturnUrl("com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess");
         return properties;
     }
 }

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

@@ -5,9 +5,7 @@ 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;
@@ -19,16 +17,12 @@ import static org.mockito.Mockito.when;
 class OmgPaymentReturnControllerTest {
 
     @Test
-    void exposesPaymentResultAndBackContracts() throws Exception {
+    void exposesPaymentResultContractOnly() throws Exception {
         Method result = OmgPaymentReturnController.class.getDeclaredMethod("result", OmgNotifyRequest.class);
-        Method back = OmgPaymentReturnController.class.getDeclaredMethod("back", String.class);
 
         assertArrayEquals(new String[]{"/result"}, result.getAnnotation(PostMapping.class).value());
-        assertArrayEquals(new String[]{"/back"}, back.getAnnotation(GetMapping.class).value());
         assertNotNull(result.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

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

@@ -15,7 +15,7 @@
 - **Method/CT**:`POST`,`application/x-www-form-urlencoded`
 - **方式**:后端组参 + 计算 CheckMacValue 返回 form 字段,**前端 Form Post** 跳转 OMG 收银台(不可 iframe;iOS 不可另开窗)
 - **必填请求参数**:`MerchantID`、`MerchantTradeNo`(=OMG+ddId)、`MerchantTradeDate`(yyyy/MM/dd HH:mm:ss)、`PaymentType=aio`、`TotalAmount`(整数元)、`ReturnURL`、`ChoosePayment=Credit`、`CheckMacValue`、`EncryptType=1`
-- **本项目固定参数**:`UnionPay=2`(隐藏银联)、`InvoiceMark=N`、`NeedExtraPaidInfo=Y`、`OrderResultURL`、`ClientBackURL`;新订单不得传 `PaymentInfoURL`、`ClientRedirectURL` 或延期缴费期限参数。
+- **本项目固定参数**:`UnionPay=2`(隐藏银联)、`InvoiceMark=N`、`NeedExtraPaidInfo=Y`、`OrderResultURL`;新订单不得传 `ClientBackURL`、`PaymentInfoURL`、`ClientRedirectURL` 或延期缴费期限参数。
 - **固定**:`InvoiceMark=N`(发票走 ezPay);`ReturnURL ≠ OrderResultURL`;`MerchantTradeNo` 全平台唯一
 
 ### A2. 付款结果回调 — ReturnURL(盘合 → 我方,US2)
@@ -56,11 +56,7 @@
 ### B3. 支付完成返回页 — `POST /pay/omg/result`(US2,@Anonymous)
 
 - 作为 `OrderResultURL` 接收 OMG Client POST,使用完整 DTO 严格解析并按支付尝试凭证快照验签;未知额外字段和空值全部进入 CheckMacValue。
-- 验签通过后只返回 no-store HTML,通过配置的 HTTPS Universal/App Link 唤醒 App;不修改订单状态,App 必须再调 `/pay/omg/query`。
-
-### B3b. 普通返回页 — `GET /pay/omg/back?merchantTradeNo=`(@Anonymous)
-
-- 作为 `ClientBackURL`,只验证 MerchantTradeNo 能定位新支付尝试并引导返回 App;OMG 不携带付款结果,因此不得据此改变或宣称付款状态。
+- 验签通过后只返回 no-store HTML,固定跳转 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`;不修改订单状态,App 必须再调 `/pay/omg/query`。
 
 ### B5. 订单查询/被动补单 — `POST /pay/omg/query`(US6 方案A,@Anonymous @Auth,Header token)
 

+ 7 - 9
specs/016-omg-payment/frontend-integration.md

@@ -110,7 +110,6 @@ Content-Type: application/json
       "InvoiceMark": "N",
       "NeedExtraPaidInfo": "Y",
       "OrderResultURL": "https://api.xxx.com/pay/omg/result",
-      "ClientBackURL": "https://api.xxx.com/pay/omg/back?merchantTradeNo=...",
       "CheckMacValue": "A1B2C3D4E5F6...(64位)"
     }
   }
@@ -131,12 +130,12 @@ Content-Type: application/json
 
 ---
 
-### 3.2 支付结果返回 — `POST /pay/omg/result` / `GET /pay/omg/back`
+### 3.2 支付结果返回 — `POST /pay/omg/result`
 
-**不是前端直接调的接口**。付款完成后 OMG Client POST 到 `/pay/omg/result`;用户点收银台“返回商店”时进入 `/pay/omg/back`。两个入口都只返回 no-store 安全桥接页,通过配置的 App Link 唤醒 App,不修改支付状态。
+**不是前端直接调的接口**。付款完成后 OMG Client POST 到 `/pay/omg/result`,后端返回 no-store 安全桥接页并唤醒 App,不修改支付状态。
 
 ```
-{omgpay.app-return-url}?orderId=DD202608060001
+com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId=DD202608060001
 ```
 
 App 打开后必须调用 `/pay/omg/query` 确认结果;`/result` 验签通过也不等于可以直接宣称已付款。
@@ -269,10 +268,10 @@ export default {
 
 ### 4.3 支付结果页 `pages/pay/omgResult`
 
-后端 `/pay/omg/result` 或 `/pay/omg/back` 返回桥接页并尝试打开 `omgpay.app-return-url`。App 结果页取得 `orderId` 后查询后端确认支付状态。
+后端 `/pay/omg/result` 返回桥接页并打开 `pages/OrderList/paySuccess/paySuccess`。App 结果页取得 `ddId` 后查询后端确认支付状态。
 
 ```js
-// App Link 对应页面加载后,从链接参数取得 orderId 并开始查询。
+// 支付成功页面加载后,从链接参数取得 ddId 并开始查询。
 ```
 
 原生订单详情页(回来后)**轮询确认状态**:
@@ -323,11 +322,10 @@ const poll = (ddId) => {
 | 配置项 | 含义 | 需要的值(示例) |
 |--------|------|----------------|
 | `omgpay.order-result-url` | OMG 付款完成 Client POST 地址 | `https://api.xxx.com/pay/omg/result` |
-| `omgpay.client-back-url` | 收银台“返回商店”地址 | `https://api.xxx.com/pay/omg/back` |
-| `omgpay.app-return-url` | 后端桥接页唤醒 App 的 HTTPS App Link | `https://app.xxx.com/pay/omg-result` |
+| `omgpay.app-return-url` | 后端桥接页唤醒 App 的固定地址 | `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess` |
 | `omgpay.notify-url` | 服务端结果回调(前端不用管) | `https://api.xxx.com/pay/omg/notify` |
 
-> 前端需要提供已完成 iOS Universal Link / Android App Link 关联的 HTTPS 地址,交给后端配置 `omgpay.app-return-url`
+> App 必须注册 `com.twanmsdyh.app` scheme,并保证该路径映射到现有支付成功页面
 
 ---
 

+ 3 - 4
specs/016-omg-payment/plan.md

@@ -116,11 +116,10 @@ application.yml (+application-dev.yml) # 新增 omg.* 配置段
 
 ## 2026-08-13 App 返回页增量计划
 
-- 新支付表单签入 `OrderResultURL`、`ClientBackURL`,不配置 `ClientRedirectURL`。
+- 新支付表单只签入 `OrderResultURL`,不配置 `ClientBackURL` 或 `ClientRedirectURL`。
 - `POST /pay/omg/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 或包信息。
+- 不实现 `/pay/omg/back`;取消或 OTP 失败时由用户关闭收银台或返回上一页,不影响付款结果回调。
+- HTML 固定跳往 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`,订单号进行 URL 与 HTML 双重转义;同时保留手动按钮,App 页面打开后调用 `/pay/omg/query` 确认状态。
 
 ## 2026-08-13 支付方式范围重大调整
 

+ 1 - 1
specs/016-omg-payment/return-to-app-flow.md

@@ -1,6 +1,6 @@
 # OMG 支付完成回跳 App 原生页 —— 已废弃的方案研究
 
-> **废弃说明(2026-08-13)**:本文比较的是曾包含延期支付的旧方案,不是当前实现依据。当前仅支持信用卡和 Apple Pay,只保留 `/pay/omg/result`、`/pay/omg/back` 与服务端 `/pay/omg/notify`;不存在需要兼容的历史延期交易。当前契约以 `spec.md`、`plan.md`、`contracts/api.md` 为准。
+> **废弃说明(2026-08-13)**:本文比较的是曾包含延期支付的旧方案,不是当前实现依据。当前仅支持信用卡和 Apple Pay,只保留 `/pay/omg/result` 与服务端 `/pay/omg/notify`;不存在需要兼容的历史延期交易。当前契约以 `spec.md`、`plan.md`、`contracts/api.md` 为准。
 
 > 状态:已验证(2026-08-11,经并行研究工作流 + 对抗审视)
 > 范围:OMG(歐買尬/FunPoint) AIO 线上支付;客户端为 uni-app 原生 App;收银台跑在 App 内 `<web-view>`

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

@@ -163,7 +163,7 @@ OMG 服务端回调(`ReturnURL`)到达后,验签 + 幂等更新订单,
 
 - **FR-017**:系统 MUST 自动补偿仍为 `CREATED` 的当前有效支付尝试。调度器按 `next_query_time` 取有限批次,并在真实查询前用条件更新预留该行;查询确认已付款或明确失败时必须复用主动查询的验签与同一状态机,未付款或暂时异常不得关闭支付尝试。
 
-- **FR-018**:系统 MUST 将新订单的 `OrderResultURL`、`ClientBackURL` 指向新 `omgpay` 后端入口,由后端返回禁止缓存的轻量 HTML 并通过已配置的 HTTPS Universal Link/App Link 唤醒 App。`OrderResultURL` 的全部实际回传字段(包括未知额外字段与空值)必须参加 CheckMacValue 验签;入口仅负责导航,不得直接改变付款状态。App 唤醒后必须调用 `/pay/omg/query` 确认最终状态。
+- **FR-018**:系统 MUST 将新订单的 `OrderResultURL` 指向 `/pay/omg/result`,由后端返回禁止缓存的轻量 HTML,并固定唤醒 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`。不得生成 `ClientBackURL`。`OrderResultURL` 的全部实际回传字段(包括未知额外字段与空值)必须参加 CheckMacValue 验签;入口仅负责导航,不得直接改变付款状态。App 唤醒后必须调用 `/pay/omg/query` 确认最终状态。
 
 ### Key Entities *(include if feature involves data)*
 

+ 6 - 6
specs/016-omg-payment/tasks.md

@@ -134,7 +134,7 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 **Purpose**: 多 story 共享的收尾与护栏
 
 - [ ] T037 [P] 四语 i18n 统一校验:foodie-store 与 foodie-admin-vue 的 zh.js/tw.js/en.js/vi.js 中 OMG 支付相关 key 四文件齐全、命名有意义、位于正确对象层级(用 i18n-consistency-checker 或人工核)
-- [x] T038 [P] 安全复核:`/pay/omg/notify`、`/pay/omg/result`、`/pay/omg/back` 为外部返回入口;HashKey/HashIV 不出现在前端响应或日志;CheckMacValue 验签、金额校验与 `SimulatePaid` 守卫不可绕过
+- [x] T038 [P] 安全复核:`/pay/omg/notify`、`/pay/omg/result` 为外部返回入口;HashKey/HashIV 不出现在前端响应或日志;CheckMacValue 验签、金额校验与 `SimulatePaid` 守卫不可绕过
 - [x] T039 [P] **零蓝新依赖复核**:grep `com.ruoyi.app.utils.omg` 及 `OmgPayController`/`PosStoreOmg*`/`PosOrderOmg*` 全部 OMG 新建类,确认无任何 `newebpay` import(必须为 0,违反硬约束)
 - [ ] T040 运行 `specs/016-omg-payment/quickstart.md` 全部场景 A–E 端到端验证(stage + 测试卡 + 内网穿透),记录结果(退款场景 E 因 stage 不可用需正式小额/mock)
 - [x] T041 文档一致性:确认 spec.md/plan.md/tasks.md 描述一致;`updatesql/sql.md` 中 SQL 齐全(三建表 + OMG 菜单权限)
@@ -214,15 +214,15 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 ## Phase 14: OMG 后端返回页与 App 唤醒(Priority: P1)
 
-- [x] T081 创建表单加入 `OrderResultURL`、`ClientBackURL`,两个地址和 App Link 均通过 `omgpay` 配置并执行 HTTPS/path 安全校验
+- [x] T081 创建表单加入 `OrderResultURL`,不生成 `ClientBackURL`;返回页固定使用项目 App scheme 与支付结果页面
 - [x] T082 新建 Client POST 验签服务,复用完整原始表单 DTO;未知额外字段与空值全部参与 CheckMacValue,验签页不写订单状态。
-- [x] T083 新建 `/pay/omg/result`、`/pay/omg/back` 控制器和 no-store/CSP HTML 渲染器,支持自动唤醒与手动按钮
+- [x] T083 新建 `/pay/omg/result` 控制器和 no-store/CSP HTML 渲染器,自动跳转 `pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}` 并保留手动按钮;不实现 `/pay/omg/back`
 - [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 信息不在本后端工作区,不猜测其值
+- [x] T085 增加表单字段、固定 App scheme/path、完整验签、额外字段篡改、单一返回路由、HTML 头和原始请求缓存测试源码。
+- [x] T086 固定返回 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`;App 页面打开后调用 `/pay/omg/query`
 - [ ] T087 全部 OMG 功能调整结束后统一运行 Maven 编译、定向测试和 stage 联调;本批按用户要求暂不执行。
 
-**Checkpoint**:后端返回页已实现;部署前必须配置真实 `OMG_APP_RETURN_URL`,否则安全页只提示尚未配置,不会跳转
+**Checkpoint**:后端支付结果返回页固定唤醒 App 的 `pages/OrderList/paySuccess/paySuccess`,参数名为 `ddId`
 
 ---