Browse Source

修复:绕过 iOS OMG 回跳 Bridge 异常

qmj 1 month ago
parent
commit
e5ec055e55

+ 14 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentReturnPageRenderer.java

@@ -70,6 +70,8 @@ public class OmgPaymentReturnPageRenderer {
                 + "function orderRef(value){try{var id=new URL(value).searchParams.get('ddId')||'';return id?'***'+id.slice(-4):'';}"
                 + "catch(error){return '';}}"
                 + "function routePath(value){return String(value||'').split('?')[0];}"
+                + "function errorSummary(error){return {name:error&&error.name?String(error.name):'Error',"
+                + "message:error&&error.message?String(error.message).slice(0,160):''};}"
                 + "function snapshot(details){var data={elapsedMs:Date.now()-startedAt,readyState:document.readyState,"
                 + "visibilityState:document.visibilityState,hidden:document.hidden,hasFocus:document.hasFocus()};"
                 + "if(details){Object.keys(details).forEach(function(key){data[key]=details[key];});}return data;}"
@@ -86,7 +88,15 @@ public class OmgPaymentReturnPageRenderer {
                 + "function bridgeAvailable(){return !!(window.uni&&uni.webView&&uni.webView.getEnv&&uni.webView.redirectTo);}"
                 + "function schemeFallback(reason){if(handoffObserved||schemeStarted){return;}schemeStarted=true;"
                 + "log('scheme_fallback_start',{reason:reason,target:targetSummary(target),orderRef:orderRef(target)});"
-                + "try{window.location.replace(target);}catch(error){log('scheme_fallback_exception',{name:error&&error.name?String(error.name):'Error'});}}"
+                + "try{window.location.replace(target);}catch(error){log('scheme_fallback_exception',errorSummary(error));}}"
+                + "function iosParentRedirect(trigger){if(!/iPhone|iPad|iPod/i.test(navigator.userAgent)||!window.plus||!plus.webview){return false;}"
+                + "try{var current=plus.webview.currentWebview(),parent=current&&current.parent?current.parent():null;"
+                + "log('ios_parent_redirect_environment',{trigger:trigger,current:!!current,parent:!!parent});"
+                + "if(!parent||typeof parent.evalJS!=='function'){return false;}"
+                + "log('ios_parent_redirect_start',{trigger:trigger,routePath:routePath(appRoute),orderRef:orderRef(target)});"
+                + "parent.evalJS('uni.redirectTo({url:'+JSON.stringify(appRoute)+'})');"
+                + "setTimeout(function(){if(!handoffObserved){schemeFallback('ios_parent_no_handoff');}},1200);return true;}"
+                + "catch(error){log('ios_parent_redirect_exception',errorSummary(error));return false;}}"
                 + "function bridgeRedirect(trigger,force){if(handoffObserved||(bridgeStarted&&!force)||!bridgeAvailable()){return false;}"
                 + "bridgeStarted=true;var envResolved=false;var envTimer=setTimeout(function(){if(!envResolved){"
                 + "log('bridge_environment_timeout',{trigger:trigger});schemeFallback('bridge_environment_timeout');}},500);"
@@ -94,11 +104,12 @@ public class OmgPaymentReturnPageRenderer {
                 + "log('bridge_environment',{trigger:trigger,plus:env.plus===true,nvue:env.nvue===true,uvue:env.uvue===true,h5:env.h5===true});"
                 + "if(!(env.plus||env.nvue||env.uvue)){schemeFallback('bridge_not_app');return;}"
                 + "log('bridge_redirect_start',{trigger:trigger,routePath:routePath(appRoute),orderRef:orderRef(target)});"
+                + "if(env.plus&&iosParentRedirect(trigger)){return;}"
                 + "try{uni.webView.redirectTo({url:appRoute});setTimeout(function(){if(!handoffObserved){"
                 + "schemeFallback('bridge_no_handoff');}},1200);}catch(error){log('bridge_redirect_exception',"
-                + "{name:error&&error.name?String(error.name):'Error'});schemeFallback('bridge_exception');}});}"
+                + "errorSummary(error));schemeFallback('bridge_exception');}});}"
                 + "catch(error){envResolved=true;clearTimeout(envTimer);log('bridge_environment_exception',"
-                + "{name:error&&error.name?String(error.name):'Error'});schemeFallback('bridge_environment_exception');}return true;}"
+                + "errorSummary(error));schemeFallback('bridge_environment_exception');}return true;}"
                 + "link.addEventListener('touchstart',function(event){log('manual_open_touch',{isTrusted:event.isTrusted===true,"
                 + "target:targetSummary(target),orderRef:orderRef(target)});},{passive:true});"
                 + "link.addEventListener('click',function(event){log('manual_open_click',{isTrusted:event.isTrusted===true,"

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

@@ -108,6 +108,10 @@ class OmgPaymentClientReturnServiceTest {
                 () -> assertTrue(page.html().contains("UniAppJSBridgeReady")),
                 () -> assertTrue(page.html().contains("uni.webView.getEnv")),
                 () -> assertTrue(page.html().contains("uni.webView.redirectTo")),
+                () -> assertTrue(page.html().contains("plus.webview.currentWebview")),
+                () -> assertTrue(page.html().contains("current.parent()")),
+                () -> assertTrue(page.html().contains("parent.evalJS")),
+                () -> assertTrue(page.html().contains("ios_parent_redirect_start")),
                 () -> assertTrue(page.html().contains("bridge_redirect_start")),
                 () -> assertTrue(page.html().contains("scheme_fallback_start")),
                 () -> assertTrue(page.html().contains("routePath:routePath(appRoute)")),

+ 1 - 1
specs/016-omg-payment/contracts/api.md

@@ -56,7 +56,7 @@
 ### B3. 支付完成返回页 — `POST /pay/omg/result`(US2,@Anonymous)
 
 - 作为 `OrderResultURL` 接收 OMG Client POST,使用完整 DTO 严格解析并按支付尝试凭证快照验签;未知额外字段和空值全部进入 CheckMacValue。
-- 验签通过后只返回 no-store HTML:从匿名只读的 `GET /pay/omg/bridge.js` 加载后端自托管 `uni.webview.1.5.8.js`,在 `UniAppJSBridgeReady` 后优先调用 `uni.webView.redirectTo({ url: '/pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}' })`;Bridge 不可用、非 App 环境或未完成页面交接时,回退到 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`,手动按钮保留同一 Scheme。该入口不修改订单状态,App 必须再调 `/pay/omg/query`。
+- 验签通过后只返回 no-store HTML:从匿名只读的 `GET /pay/omg/bridge.js` 加载后端自托管 `uni.webview.1.5.8.js`;在 `UniAppJSBridgeReady` 后,iOS App-Plus 优先通过支付子 WebView 的父 uni-app 页面执行 `uni.redirectTo`,其他 App 环境使用 `uni.webView.redirectTo`,统一打开 `/pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`。Bridge 不可用、非 App 环境或未完成页面交接时,回退到 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`,手动按钮保留同一 Scheme。该入口不修改订单状态,App 必须再调 `/pay/omg/query`。
 
 ### B5. 订单查询/被动补单 — `POST /pay/omg/query`(US6 方案A,@Anonymous @Auth,Header token)
 

+ 1 - 1
specs/016-omg-payment/frontend-integration.md

@@ -269,7 +269,7 @@ export default {
 
 ### 4.3 支付结果页 `pages/pay/omgResult`
 
-后端 `/pay/omg/result` 返回桥接页并打开 `pages/OrderList/paySuccess/paySuccess`。桥接页通过匿名只读地址 `/pay/omg/bridge.js` 加载后端自托管的 `uni.webview.1.5.8.js`:在 App 的 `<web-view>` 内优先等待 `UniAppJSBridgeReady` 并调用 `uni.webView.redirectTo`,Bridge 不可用或未完成页面交接时再使用固定 App Scheme。Android、iOS 共用这套返回页逻辑,外部浏览器仍可通过 Scheme 返回。
+后端 `/pay/omg/result` 返回桥接页并打开 `pages/OrderList/paySuccess/paySuccess`。桥接页通过匿名只读地址 `/pay/omg/bridge.js` 加载后端自托管的 `uni.webview.1.5.8.js`:在 App 的 `<web-view>` 内等待 `UniAppJSBridgeReady`;iOS App-Plus 优先让支付子 WebView 的父 uni-app 页面执行 `uni.redirectTo`,规避旧运行时内部 `UniPlusBridge` 不存在造成的 `ReferenceError`,Android 等其他环境继续调用 `uni.webView.redirectTo`。Bridge 不可用或未完成页面交接时再使用固定 App Scheme,外部浏览器仍可通过 Scheme 返回。
 
 App 前端不需要加载 Bridge SDK,也不需要在支付页增加按钮;只需确保目标页面已在 `pages.json` 注册且不是 `tabBar` 页面:
 

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

@@ -148,6 +148,12 @@ application.yml (+application-dev.yml) # 新增 omg.* 配置段
 - 真机日志只有 `page_loaded` 与 `bridge_ready_timeout`,没有 `bridge_ready`、`bridge_environment` 或 JavaScript 异常;线上访问原 `/static/omg/uni.webview.1.5.8.js` 实际返回 `401` JSON,SDK 因 `nosniff` 未执行。
 - 不放开整个 `/static/**`,改由 `OmgPaymentReturnController` 的 `GET /pay/omg/bridge.js` 以 `@Anonymous`、`application/javascript;charset=UTF-8` 和 `no-store` 返回已打包 SDK;返回页只替换脚本地址,其余支付状态、路由和 Scheme 兜底逻辑不变。
 
+## 2026-08-14 iOS 父 WebView 回跳修复增量计划
+
+- 真机日志确认 SDK 已加载且 `getEnv` 返回 `plus=true`,但 `uni.webView.redirectTo` 同步抛出 `ReferenceError`;SDK 在当前运行时找不到 `__uniapp__service` 后会转到启动 WebView 调用 `UniPlusBridge`,失败点已定位到 Bridge 内部路由派发。
+- iOS App-Plus 环境不再进入该派发分支,改为从支付子 WebView 获取父 WebView,并通过 `evalJS` 让父 uni-app 页面执行 `uni.redirectTo`;该父页面即 App 当前的支付 WebView 宿主页。
+- Android 和其他环境继续使用官方 `uni.webView.redirectTo`;父 WebView 不可用、执行异常或未发生交接时保留固定 Scheme 兜底。异常日志增加脱敏后的异常消息,便于真机复核。
+
 ## Complexity Tracking
 
 > Constitution Check 无违规,无需填表。

+ 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` 指向 `/pay/omg/result`,由后端返回禁止缓存的轻量 HTML。中转页 MUST 从匿名只读地址 `/pay/omg/bridge.js` 加载后端自托管的 `uni.webview.1.5.8.js`,在 `UniAppJSBridgeReady` 后确认处于 uni-app App 环境并优先调用 `uni.webView.redirectTo` 打开 `/pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`;Bridge 不可用、环境不是 App 或调用后未发生页面交接时,MUST 回退到 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`,手动按钮也 MUST 保留同一 Scheme 兜底。不得生成 `ClientBackURL`。`OrderResultURL` 的全部实际回传字段(包括未知额外字段与空值)必须参加 CheckMacValue 验签;入口仅负责导航,不得直接改变付款状态。App 页面打开后必须调用 `/pay/omg/query` 确认最终状态。
+- **FR-018**:系统 MUST 将新订单的 `OrderResultURL` 指向 `/pay/omg/result`,由后端返回禁止缓存的轻量 HTML。中转页 MUST 从匿名只读地址 `/pay/omg/bridge.js` 加载后端自托管的 `uni.webview.1.5.8.js`,在 `UniAppJSBridgeReady` 后确认处于 uni-app App 环境;iOS App-Plus 环境 MUST 优先让支付子 WebView 的父 uni-app 页面执行 `uni.redirectTo`,其他 App 环境调用 `uni.webView.redirectTo`,目标均为 `/pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`。Bridge 不可用、环境不是 App 或调用后未发生页面交接时,MUST 回退到 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`,手动按钮也 MUST 保留同一 Scheme 兜底。不得生成 `ClientBackURL`。`OrderResultURL` 的全部实际回传字段(包括未知额外字段与空值)必须参加 CheckMacValue 验签;入口仅负责导航,不得直接改变付款状态。App 页面打开后必须调用 `/pay/omg/query` 确认最终状态。
 - **FR-019**:支付结果中转页 MUST 为 Bridge 就绪/环境/跳转、Scheme 兜底、自动唤起、手动点击、页面可见性/焦点变化和 JavaScript 异常输出统一前缀的浏览器诊断日志,并在自动尝试后输出分阶段观察结果;日志载荷 MUST 序列化为 JSON,避免 HBuilderX 只显示 `[object Object]`。日志只能包含浏览器环境、页面生命周期、固定 App 路由、目标 Scheme 的协议/主机/路径及脱敏订单号,不得包含 token、CheckMacValue、HashKey、HashIV 或 OMG 原始支付表单。App 端必须同步记录生命周期、启动参数、`newintent`、当前路由和支付 WebView 事件;Bridge 内部路由成功不要求出现 `newintent`。本期日志仅输出到本地调试控制台,不新增远程上报接口。
 
 ### Key Entities *(include if feature involves data)*

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

@@ -332,6 +332,9 @@ Task: "OmgPay HTTP 客户端骨架 in ruoyi-admin/.../utils/omg/OmgPay.java"
 
 ---
 
+- [x] T104 根据 iOS 真机 `bridge_redirect_exception: ReferenceError` 追踪官方 SDK 派发链路,确认当前 App 运行时缺少 SDK 依赖的启动 WebView `UniPlusBridge`。
+- [x] T105 在返回页增加 iOS 父 WebView `evalJS -> uni.redirectTo` 路径并保留 Android 官方 Bridge 与固定 Scheme 兜底,同时为异常日志补充脱敏 message。
+
 ## Notes
 
 - [P] 任务 = 不同文件、无依赖;同文件多改动须串行

+ 11 - 7
specs/020-omg-payment-rebuild/omg-app-integration.md

@@ -15,7 +15,7 @@ OMG 是网页托管式收银台,App 不需要接入 OMG SDK。前端只需要
 1. 创建订单时把 OMG 支付方式设置为 `payType = "2"`。
 2. 调用 `POST /pay/omg/create` 获取 `gatewayUrl` 和完整的 `formFields`。
 3. 在当前 App WebView 页面中,把所有 `formFields` 原样以 HTML Form POST 到 `gatewayUrl`。
-4. 用户付款后,OMG 将浏览器 POST 到后端 `/pay/omg/result`;后端验证返回资料后,通过 App Scheme 打开支付结果页。
+4. 用户付款后,OMG 将浏览器 POST 到后端 `/pay/omg/result`;后端验证返回资料后,优先通过 uni-app Bridge 打开支付结果页,Bridge 不可用时再通过 App Scheme 兜底。
 5. App 结果页取得 `ddId`,调用 `POST /pay/omg/query` 确认最终支付状态。
 6. 只有 query 返回 `status = "PAID"` 才能展示支付成功。
 
@@ -44,10 +44,11 @@ OMG 是网页托管式收银台,App 不需要接入 OMG SDK。前端只需要
              | POST /pay/omg/notify           | POST /pay/omg/result
              | 前端不调用                     | 前端不直接调用
              v                               v
-[后端验签并更新付款事实]            [后端安全桥接页打开 App Scheme]
+[后端验签并更新付款事实]            [后端安全桥接页打开 App 结果路由]
                                              |
-                                             | com.twanmsdyh.app://pages/...
-                                             | ?ddId=<业务订单号>
+                                             | iOS: 父 uni-app 页面 redirectTo
+                                             | 其他环境: uni.webView.redirectTo
+                                             | 失败时: App Scheme 兜底
                                              v
                                   [App 支付结果页]
                                              |
@@ -328,13 +329,15 @@ Content-Type: application/x-www-form-urlencoded
 
 该接口由 OMG 收银台调用,不是 App API。后端会验证返回表单的签名、商户号、OMG 交易编号和金额,然后返回一个禁止缓存的安全桥接页面。
 
-桥接页面自动打开:
+桥接页面的目标路由固定为:
 
 ```text
 com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId=<URL 编码后的业务订单号>
 ```
 
-页面同时保留“返回 App”按钮,自动唤起失败时用户可手动点击。
+在 uni-app App 的 `<web-view>` 中,页面先加载后端自托管的 Bridge SDK。iOS App-Plus 环境由支付子 WebView 的父 uni-app 页面执行 `uni.redirectTo`,避免旧运行时调用不存在的 `UniPlusBridge`;Android 等其他 App 环境继续使用 `uni.webView.redirectTo`。上述方式不可用或没有完成页面交接时,页面才使用 `com.twanmsdyh.app` Scheme 兜底。
+
+页面同时保留“返回 App”按钮,自动跳转失败时用户可手动点击。App 端现有支付 WebView 页面不需要为本次 iOS 修复新增接口或页面,但仍必须保留 Scheme 注册作为兜底。
 
 ### 5.2 App 必须完成的配置
 
@@ -593,7 +596,7 @@ Content-Type: application/x-www-form-urlencoded
 这是 OMG 收银台到后端的浏览器返回接口:
 
 - App 不主动调用。
-- 后端只验证返回资料并生成 App Scheme 桥接页。
+- 后端只验证返回资料并生成 App 路由桥接页;iOS 优先由父 uni-app 页面执行路由,其他环境使用官方 Bridge,失败时才使用 App Scheme。
 - 该接口本身不修改支付状态。
 
 ## 8. 退款接口:当前不可用
@@ -667,6 +670,7 @@ token: <用户登录 token>
 
 - [ ] App 已注册 `com.twanmsdyh.app` Scheme。
 - [ ] Scheme 路由可打开 `pages/OrderList/paySuccess/paySuccess`。
+- [ ] iOS 从 OMG 信用卡付款完成后可由支付 WebView 自动进入 `pages/OrderList/paySuccess/paySuccess`。
 - [ ] 结果页能读取 `ddId`。
 - [ ] 结果页把 `ddId` 作为 `orderId` 调用 query。
 - [ ] `PAID` 才展示成功。