Răsfoiți Sursa

修复:补充 OMG iOS 回跳诊断日志

qmj 2 săptămâni în urmă
părinte
comite
04622af49d

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

@@ -22,7 +22,7 @@ public class OmgPaymentReturnPageRenderer {
         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>");
+                        + diagnosticScript());
         return new OmgPaymentReturnPage(200, html);
     }
 
@@ -50,6 +50,38 @@ public class OmgPaymentReturnPageRenderer {
                 + message + "</p>" + action + "</main></body></html>";
     }
 
+    private static String diagnosticScript() {
+        return "<script>(function(){'use strict';"
+                + "var PREFIX='[OMG-RETURN]',startedAt=Date.now(),link=document.getElementById('open-app'),target=link.href;"
+                + "function targetSummary(value){try{var parsed=new URL(value);return {protocol:parsed.protocol,host:parsed.host,pathname:parsed.pathname};}"
+                + "catch(error){return {parseError:true};}}"
+                + "function orderRef(value){try{var id=new URL(value).searchParams.get('ddId')||'';return id?'***'+id.slice(-4):'';}"
+                + "catch(error){return '';}}"
+                + "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;}"
+                + "function log(event,details){console.info(PREFIX,event,snapshot(details));}"
+                + "function lifecycle(event){log(event.type,{persisted:event.persisted===true});}"
+                + "document.addEventListener('visibilitychange',function(event){lifecycle(event);});"
+                + "window.addEventListener('pagehide',lifecycle);window.addEventListener('pageshow',lifecycle);"
+                + "window.addEventListener('blur',lifecycle);window.addEventListener('focus',lifecycle);"
+                + "window.addEventListener('beforeunload',lifecycle);"
+                + "window.addEventListener('error',function(event){log('js_error',{line:event.lineno||0,column:event.colno||0});});"
+                + "window.addEventListener('unhandledrejection',function(event){var reason=event.reason;"
+                + "log('unhandledrejection',{reasonType:typeof reason,reasonName:reason&&reason.name?String(reason.name):''});});"
+                + "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,"
+                + "target:targetSummary(target),orderRef:orderRef(target)});});"
+                + "log('page_loaded',{userAgent:navigator.userAgent,platform:navigator.platform||'',language:navigator.language||'',"
+                + "page:{origin:window.location.origin,pathname:window.location.pathname},target:targetSummary(target),orderRef:orderRef(target)});"
+                + "[250,1000,3000].forEach(function(delay){setTimeout(function(){log('auto_open_observation',{delayMs:delay,"
+                + "stillVisible:!document.hidden,target:targetSummary(target),orderRef:orderRef(target)});},delay);});"
+                + "log('auto_open_start',{target:targetSummary(target),orderRef:orderRef(target)});"
+                + "try{window.location.replace(target);}catch(error){log('auto_open_exception',{name:error&&error.name?String(error.name):'Error'});}"
+                + "})();</script>";
+    }
+
     private static String encode(String value) {
         return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8)
                 .replace("+", "%20");

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

@@ -70,6 +70,30 @@ class OmgPaymentClientReturnServiceTest {
         assertFalse(page.html().contains("无法返回 App"));
     }
 
+    @Test
+    void verifiedPaymentResultProvidesRedactedBrowserDiagnosticsForIosHandoff() {
+        OmgPaymentReturnPage page = service.paymentResult(signedRequest(paymentFields()));
+
+        assertEquals(200, page.statusCode());
+        assertAll(
+                () -> assertTrue(page.html().contains("[OMG-RETURN]")),
+                () -> assertTrue(page.html().contains("page_loaded")),
+                () -> assertTrue(page.html().contains("auto_open_start")),
+                () -> assertTrue(page.html().contains("auto_open_observation")),
+                () -> assertTrue(page.html().contains("manual_open_touch")),
+                () -> assertTrue(page.html().contains("manual_open_click")),
+                () -> assertTrue(page.html().contains("visibilitychange")),
+                () -> assertTrue(page.html().contains("pagehide")),
+                () -> assertTrue(page.html().contains("pageshow")),
+                () -> assertTrue(page.html().contains("unhandledrejection")),
+                () -> assertTrue(page.html().contains("targetSummary")),
+                () -> assertTrue(page.html().contains("orderRef")),
+                () -> assertFalse(page.html().contains("rawForm")),
+                () -> assertFalse(page.html().contains(HASH_KEY)),
+                () -> assertFalse(page.html().contains(HASH_IV)),
+                () -> assertFalse(page.html().contains("CheckMacValue")));
+    }
+
     @Test
     void tamperingAnyExtraFieldRejectsTheWholeClientResult() {
         LinkedHashMap<String, String> fields = paymentFields();

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

@@ -1,6 +1,6 @@
 # OMG(歐買尬/FunPoint)支付 — 前端接入文档
 
-**适用端**:客户 App(uni-app) | **后端分支**:`OmgPayController` 已完成 | **日期**:2026-08-06
+**适用端**:客户 App(uni-app) | **后端分支**:`OmgPayController` 已完成 | **日期**:2026-08-14
 
 ---
 
@@ -59,6 +59,7 @@ OMG(欧买尬/FunPoint)是**网页托管式支付网关**(AIO = All-In-One
 | 5 | (可选)取消订单退款:调 `/pay/omg/refund` | 订单取消逻辑 |
 | 6 | 告诉后端两个前端地址(见 §6 配置依赖) | 沟通 |
 | 7 | 所有面向用户文字走 i18n 4 语言 | zh/tw/en/vi |
+| 8 | iOS 联调时同时收集 `[OMG-RETURN]` 与 `[OMG-APP]` 日志 | HBuilderX + Safari Web Inspector |
 
 ---
 
@@ -296,6 +297,203 @@ const poll = (ddId) => {
 
 > 轮询建议:每 2s 一次,最多 15 次(30s);超过仍未成功提示「支付结果确认中,请稍后在订单列表查看」。
 
+### 4.4 iOS / uni-app App 唤起全链路诊断日志
+
+后端中转页会在浏览器控制台输出统一前缀 `[OMG-RETURN]`,覆盖页面加载、自动唤起、手动触摸/点击、页面可见性和脚本异常。App 端需要按下面代码输出 `[OMG-APP]`,两组日志必须在同一次支付中一起收集。
+
+所有日志只保留 Scheme 的协议/主机/路径、参数名和订单号后四位,不打印 token、CheckMacValue、OMG 表单或完整订单号。
+
+#### 4.4.1 `App.vue`:App 生命周期和 Scheme 参数
+
+```js
+const OMG_APP_LOG = '[OMG-APP]'
+let newIntentRegistered = false
+
+function summarizeSchemeArgument(raw) {
+  const value = typeof raw === 'string' ? raw : ''
+  if (!value) return { present: false }
+
+  const url = value.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)([^?#]*)/i)
+  const idMatch = value.match(/[?&]ddId=([^&#]*)/i)
+  let ddId = ''
+  if (idMatch) {
+    try { ddId = decodeURIComponent(idMatch[1]) } catch (e) { ddId = '' }
+  }
+  return {
+    present: true,
+    protocol: url ? url[1].toLowerCase() + ':' : 'unknown',
+    host: url ? url[2] : '',
+    pathname: url ? url[3] : '',
+    orderRef: ddId ? '***' + ddId.slice(-4) : ''
+  }
+}
+
+function currentRouteSummary() {
+  const pages = getCurrentPages()
+  const page = pages.length ? pages[pages.length - 1] : null
+  return {
+    route: page && page.route ? page.route : '',
+    optionKeys: page && page.options ? Object.keys(page.options) : []
+  }
+}
+
+function logRuntimeArguments(source) {
+  // #ifdef APP-PLUS
+  const runtimeReady = typeof plus !== 'undefined' && plus.runtime
+  console.info(OMG_APP_LOG, source, {
+    runtimeReady: !!runtimeReady,
+    launcher: runtimeReady ? plus.runtime.launcher : '',
+    argument: summarizeSchemeArgument(runtimeReady ? plus.runtime.arguments : ''),
+    route: currentRouteSummary()
+  })
+  // #endif
+}
+
+function registerNewIntentLogger() {
+  // #ifdef APP-PLUS
+  if (newIntentRegistered || typeof document === 'undefined') return
+
+  const register = () => {
+    if (newIntentRegistered) return
+    document.addEventListener('newintent', () => logRuntimeArguments('newintent'), false)
+    newIntentRegistered = true
+    console.info(OMG_APP_LOG, 'newintent_listener_registered')
+  }
+
+  if (typeof plus !== 'undefined') register()
+  else document.addEventListener('plusready', register, false)
+  // #endif
+}
+
+export default {
+  onLaunch(options) {
+    console.info(OMG_APP_LOG, 'onLaunch', {
+      path: options && options.path ? options.path : '',
+      queryKeys: options && options.query ? Object.keys(options.query) : []
+    })
+    registerNewIntentLogger()
+    logRuntimeArguments('onLaunch_runtime')
+  },
+  onShow(options) {
+    console.info(OMG_APP_LOG, 'onShow', {
+      path: options && options.path ? options.path : '',
+      queryKeys: options && options.query ? Object.keys(options.query) : [],
+      route: currentRouteSummary()
+    })
+    logRuntimeArguments('onShow_runtime')
+  },
+  onHide() {
+    console.info(OMG_APP_LOG, 'onHide', { route: currentRouteSummary() })
+  },
+  onError(error) {
+    console.error(OMG_APP_LOG, 'app_error', {
+      errorType: typeof error,
+      route: currentRouteSummary()
+    })
+  }
+}
+```
+
+App 已经在前台时,第三方 Scheme 正常唤起至少应看到 `newintent` 或新的 `onShow_runtime`,其中 `argument.present=true`,且 `protocol=com.twanmsdyh.app:`。App 从未启动时,应看到 `onLaunch_runtime` 携带同样的脱敏参数摘要。
+
+#### 4.4.2 `omgCheckout.vue`:支付子 WebView 生命周期
+
+App-vue 的 `<web-view>` 在 App 平台是父页面下的子 WebView,不要依赖组件的 `@load/@error`。在现有 `omgCheckout.vue` 的 `onReady` 和 `methods` 中加入以下代码:
+
+```js
+function summarizeWebviewUrl(raw) {
+  const value = typeof raw === 'string' ? raw : ''
+  const url = value.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)([^?#]*)/i)
+  return {
+    protocol: url ? url[1].toLowerCase() + ':' : '',
+    host: url ? url[2] : '',
+    pathname: url ? url[3] : ''
+  }
+}
+
+export default {
+  data: () => ({
+    src: '',
+    omgWebviewLoggerAttached: false
+  }),
+  onLoad(opt) {
+    this.src = '/static/omgPay.html?' + decodeURIComponent(opt.pay)
+    console.info('[OMG-APP]', 'checkout_onLoad', {
+      hasPayPayload: !!opt.pay,
+      payloadLength: opt.pay ? String(opt.pay).length : 0
+    })
+  },
+  onReady() {
+    // #ifdef APP-PLUS
+    ;[100, 500, 1200].forEach(delay => {
+      setTimeout(() => this.attachOmgWebviewLogger(delay), delay)
+    })
+    // #endif
+  },
+  onShow() {
+    console.info('[OMG-APP]', 'checkout_onShow')
+  },
+  onHide() {
+    console.info('[OMG-APP]', 'checkout_onHide')
+  },
+  onUnload() {
+    console.info('[OMG-APP]', 'checkout_onUnload')
+  },
+  methods: {
+    attachOmgWebviewLogger(delay) {
+      // #ifdef APP-PLUS
+      if (this.omgWebviewLoggerAttached) return
+      const parent = this.$scope && this.$scope.$getAppWebview
+        ? this.$scope.$getAppWebview()
+        : null
+      const children = parent ? parent.children() : []
+      const child = children && children.length ? children[0] : null
+
+      if (!child) {
+        console.warn('[OMG-APP]', 'checkout_webview_missing', { delayMs: delay })
+        return
+      }
+
+      this.omgWebviewLoggerAttached = true
+      const detail = event => ({
+        event,
+        id: child.id || '',
+        url: summarizeWebviewUrl(child.getURL ? child.getURL() : '')
+      })
+      ;['loading', 'loaded', 'error', 'show', 'hide', 'close'].forEach(event => {
+        child.addEventListener(event, () => {
+          const payload = detail(event)
+          if (event === 'error') console.error('[OMG-APP]', 'checkout_webview_event', payload)
+          else console.info('[OMG-APP]', 'checkout_webview_event', payload)
+        })
+      })
+      console.info('[OMG-APP]', 'checkout_webview_logger_attached', detail('attached'))
+      // #endif
+    }
+  }
+}
+```
+
+#### 4.4.3 真机日志采集步骤
+
+1. 使用 HBuilderX 运行 iOS 真机,先清空控制台并过滤 `OMG-APP`。
+2. iPhone 打开“设置 → Safari → 高级 → Web 检查器”;Mac Safari 在“开发”菜单选择该设备和支付子 WebView,过滤 `OMG-RETURN`。
+3. 从 App 发起一笔 OMG 信用卡支付,支付完成后等待中转页停留至少 3 秒,再点击一次“返回 App”。
+4. 导出从 `[OMG-RETURN] page_loaded` 到最后一条 `auto_open_observation` 的浏览器日志,以及同一时段全部 `[OMG-APP]` 日志。
+5. 日志不得包含 token、CheckMacValue、HashKey、HashIV、完整订单号或银行卡资料;发现后先删除敏感内容再传给后端排查。
+
+#### 4.4.4 日志判读
+
+| 日志表现 | 失败边界 |
+|----------|----------|
+| 没有 `[OMG-RETURN] page_loaded` | OMG 没有进入 `/pay/omg/result`,或 Safari 检查器选错 WebView |
+| 有 `page_loaded`、`auto_open_start`,3 秒后 `stillVisible=true`,App 没有 `onHide/newintent/onShow_runtime` | 自定义 Scheme 被支付子 WebView 拒绝或未交给 iOS |
+| 中转页出现 `visibilitychange/pagehide`,但 App 没有任何新日志 | iOS 没有找到可处理该 Scheme 的安装包,重点检查 iOS `urltypes` 与重新打包结果 |
+| App 出现 `newintent/onShow_runtime` 且 `argument.present=true`,但路由没变化 | App 已收到 Scheme,路由解析或 `navigateTo/redirectTo` 逻辑有问题 |
+| App 已进入 `pages/OrderList/paySuccess/paySuccess`,但结果不更新 | 回跳已成功,继续检查 `/pay/omg/query` 请求与返回 |
+
+> `[OMG-RETURN]` 日志来自后端返回的子 WebView 页面,通常需要 Safari Web Inspector 查看;`[OMG-APP]` 来自 uni-app 逻辑层,可在 HBuilderX 真机控制台查看。两者不是同一个 JavaScript 执行环境。
+
 ## 5. 状态码 / 付款方式速查
 
 ### `payStatus`(订单 + 流水)

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

@@ -127,6 +127,14 @@ application.yml (+application-dev.yml) # 新增 omg.* 配置段
 - 新表单移除 `ExpireDate`、`StoreExpireDate`、`BarcodeATMExpireDate`、`ClientRedirectURL`,所有保留字段(含 `UnionPay`)统一参加 CheckMacValue 计算。
 - ATM、CVS、BarcodeATM、AFTEE 不开放;当前只有开发测试数据,不保留延期支付历史回调或返回路由。
 
+## 2026-08-14 iOS App 唤起诊断增量计划
+
+- 保持现有 `OrderResultURL -> /pay/omg/result -> App Scheme` 路径和支付状态判定不变,只增强可观测性。
+- 中转页以 `[OMG-RETURN]` 为统一前缀,记录页面加载、自动唤起、手动点击、`visibilitychange/pagehide/pageshow/blur/focus`、全局脚本异常和分阶段观察结果。
+- 浏览器日志只输出 Scheme 的协议、主机、路径和脱敏订单号;不输出查询参数、OMG Client POST 原文、token、CheckMacValue 或门店凭证。
+- `frontend-integration.md` 提供 uni-app App 生命周期、`plus.runtime.arguments`、`newintent`、当前路由与支付 WebView 事件的可复制日志代码;App 源码不在当前工作区,本批不直接修改 App。
+- 不新增日志上传接口或持久化表;前端通过 HBuilderX 或 iOS Safari Web Inspector 导出本地日志,先定位失败边界再决定修复方案。
+
 ## Complexity Tracking
 
 > Constitution Check 无违规,无需填表。

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

@@ -164,6 +164,7 @@ OMG 服务端回调(`ReturnURL`)到达后,验签 + 幂等更新订单,
 - **FR-017**:系统 MUST 自动补偿仍为 `CREATED` 的当前有效支付尝试。调度器按 `next_query_time` 取有限批次,并在真实查询前用条件更新预留该行;查询确认已付款或明确失败时必须复用主动查询的验签与同一状态机,未付款或暂时异常不得关闭支付尝试。
 
 - **FR-018**:系统 MUST 将新订单的 `OrderResultURL` 指向 `/pay/omg/result`,由后端返回禁止缓存的轻量 HTML,并固定唤醒 `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId={订单ddId}`。不得生成 `ClientBackURL`。`OrderResultURL` 的全部实际回传字段(包括未知额外字段与空值)必须参加 CheckMacValue 验签;入口仅负责导航,不得直接改变付款状态。App 唤醒后必须调用 `/pay/omg/query` 确认最终状态。
+- **FR-019**:支付结果中转页 MUST 为自动唤起、手动点击、页面可见性/焦点变化和 JavaScript 异常输出统一前缀的浏览器诊断日志,并在自动尝试后输出分阶段观察结果。日志只能包含浏览器环境、页面生命周期、目标 Scheme 的协议/主机/路径及脱敏订单号,不得包含 token、CheckMacValue、HashKey、HashIV 或 OMG 原始支付表单。App 端必须同步记录生命周期、启动参数、`newintent`、当前路由和支付 WebView 事件;本期日志仅输出到本地调试控制台,不新增远程上报接口。
 
 ### Key Entities *(include if feature involves data)*
 

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

@@ -237,6 +237,17 @@ description: "Task list for OMG(歐買尬/FunPoint)AIO 支付接入"
 
 ---
 
+## Phase 16: iOS App 唤起诊断(Priority: P0)
+
+- [x] T092 为中转页诊断契约增加回归测试,覆盖统一日志前缀、自动/手动唤起、页面生命周期、异常监听和敏感字段不暴露。
+- [x] T093 在 `OmgPaymentReturnPageRenderer` 增加脱敏浏览器诊断日志和分阶段唤起观察,不改变支付状态或 App Scheme 契约。
+- [x] T094 在 `frontend-integration.md` 增加 uni-app App 生命周期、Scheme/newintent、当前路由、支付 WebView 事件日志代码及日志收集步骤。
+- [x] T095 使用 JDK 21 运行返回页定向测试和 OMG 回归测试;iOS 真机日志采集由 App 接入人员部署后执行。
+
+**Checkpoint**:单次真机复现可同时取得 `[OMG-RETURN]` 浏览器日志与 `[OMG-APP]` App 日志,并据此判断失败发生在页面脚本、WebView Scheme 交接、iOS App 唤起还是 App 路由阶段。
+
+---
+
 ## Dependencies & Execution Order
 
 ### Phase 依赖