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

chore(omg-pay): switch return-to-app to scheme B (polling)

Empty omg.order-result-url so OrderResultURL is no longer sent to OMG.
The native app polls POST /pay/omg/query to drive the result page
instead of relying on OMG's foreground redirect, which only fired for
instant payments and never for ATM/CVS.

- specs/016-omg-payment/return-to-app-flow.md: document both schemes
  (bridge page vs polling-driven), comparison, and the decision
- /pay/omg/return kept as-is (dead code, harmless)

Co-Authored-By: Claude <noreply@anthropic.com>
qmj 2 недель назад
Родитель
Сommit
541ec3600b

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

@@ -43,8 +43,8 @@ omg:
   base-url: https://payment-stage.funpoint.com.tw
   # 支付结果服务端回调(须公网,OMG POST 回调 /pay/omg/notify,平台回纯串 1|OK)
   return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify
-  # 支付完成前端结果页(须公网,与 return-url 不可相同;仅引导不改订单状态
-  order-result-url: https://your-h5-domain/#/pages/payResult
+  # 方案B:留空=不向 OMG 传 OrderResultURL,支付后由 App 端轮询 /pay/omg/query 驱动回跳(见 return-to-app-flow.md
+  order-result-url:
   # ATM/超商取号服务端回调(OMG POST 回调 /pay/omg/paymentInfo)
   payment-info-url: https://foodieapi.waimai-paotui.com/pay/omg/paymentInfo
   # ATM/超商取号前端展示页(可选)

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

@@ -0,0 +1,245 @@
+# OMG 支付完成回跳 App 原生页 —— 方案对比与决策
+
+> 状态:已验证(2026-08-11,经并行研究工作流 + 对抗审视)
+> 范围:OMG(歐買尬/FunPoint) AIO 线上支付;客户端为 uni-app 原生 App;收银台跑在 App 内 `<web-view>`
+> 关联:`frontend-integration.md`(§4.3 旧思路已被本文取代)、`contracts/api.md`(B3 `/return`、B5 `/query`、B7 `/paymentInfo`、B8 `OmgReconcileTask`)
+
+## TL;DR
+
+两种可行方案:
+
+| 方案 | 一句话 | 推荐 |
+|------|--------|------|
+| **A 桥接页(主动回跳)** | OMG 付完款把 web-view POST 到后端 `/return`,后端返回含 `uni.webview.js` 的 HTML,调 `uni.webView.redirectTo` 进原生页 | 信用卡秒回,但 **ATM/超商不触发** → 体验割裂 |
+| **B 轮询驱动统一结果页** | 把 web-view 当黑盒,**原生侧轮询 `/pay/omg/query` + `/pay/omg/paymentInfo` 驱动一切**,所有方式落到同一个结果页 | **推荐** —— 所有方式一套逻辑,真正统一 |
+
+**推荐方案 B。** 信用卡比 A 少 2-3 秒"秒回感",换来全方式统一 + 少一大半代码 + 不踩 JSBridge 的坑。也是项目 `frontend-integration.md:306` 自己说的"最稳的做法"。
+
+---
+
+## 1. 背景与"统一"的真正含义
+
+有个物理限制绕不开:**ATM / 超商 / BarcodeATM / AFTEE 不可能"付完款立刻回 App"** —— 用户拿虚帐/缴费码后**离开 App、几小时甚至跨天后再去 ATM/便利店缴款**。OMG 只在服务端回调(取号时 `PaymentInfoURL`、实际缴款时 `ReturnURL`),浏览器端不回跳。这是延期支付的本质,任何方案都改不了。
+
+所以"体验统一"的正确含义不是"回 App 的时机一样"(对延期支付不可能),而是:
+
+> **所有支付方式,用户最终都落到 App 里同一个原生结果页,由同一套轮询逻辑驱动,按状态显示不同 UI。** 差异只在"结果页显示什么",不在"回 App 的路径"。
+
+方案 A 的割裂根源:桥接页**只对即时支付(信用卡)有效**,ATM/超商得另配一套 → 两套体验。方案 B 把回跳完全交给原生轮询,所有方式天然同一套。
+
+## 2. 已验证关键事实(两方案共用)
+
+来源:研究工作流(OMG 语义 agent + uni-app web-view agent + 现有代码 agent),交叉核对项目 `spec.md` / `contracts/api.md` 与绿界(ECPay)等价(OMG AIO 克隆绿界)。
+
+| 事实 | 结论 | 依据 |
+|------|------|------|
+| `OrderResultURL` 回传方式 | **浏览器 Form POST**(带 RtnCode/CheckMacValue),非 302 GET、非服务端 POST | 绿界 15076/2862;spec.md:22 |
+| `ReturnURL` | 服务端 POST,须回 `1\|OK` | OmgPayController.java:242 |
+| 即时支付(Credit)是否触发 OrderResultURL | 是 | 绿界 2878 |
+| ATM/CVS/BarcodeATM/AFTEE 是否触发 OrderResultURL | **否**(走 `PaymentInfoURL` 取号 + 线下缴款) | 绿界 2878 |
+| OrderResultURL 能否指 SPA hash 页 | **不能**(POST 不带 hash,前端也读不到 body) | HTTP 语义 |
+| 原生页 `/pages/xxx` 能否作 302 目标 | **不能**(非 HTTP URL) | uni-app 路由 |
+| `uni.webView.redirectTo` 回原生 | 可行,需 SDK `uni.webview.1.5.8.js` + 事件 `UniAppJSBridgeReady`,**仅你可控的页面** | uniapp.dcloud.net.cn/component/web-view.html |
+| `POST /pay/omg/query` | 既查 `payStatus`(0/1/2) 又自愈(`reconcileByQuery` 主动问 OMG) | OmgPayController.java:704-748 |
+| `GET /pay/omg/paymentInfo/{orderid}` | 返回 `{payType,amount,payStatus,info}`,`info` 非空 = 已取号(虚帐/缴费码+期限) | OmgPayController.java:477-502 |
+| 当前 `/return` 死循环 bug | 存在(`orderResultUrl` 同时作 OrderResultURL 与 302 目标) | OmgPayController.java:188-190 / 401-402 |
+
+> ⚠️ **OMG 官方文档(developers-stage.omg.com.tw)被安全策略挡住,未能直连核对**,「Form POST + 即时支付才触发 OrderResultURL」是从**绿界等价 + 项目 spec** 推断的。**上线前必须在 OMG stage 实测**(见 §8)。方案 B 不依赖 OMG 前景回跳,受这个推断影响最小。
+
+---
+
+## 3. 方案 A:桥接页(主动回跳)
+
+### 3.1 机制
+
+```
+用户在 <web-view> 付完款(即时支付)
+  → OMG 用浏览器 Form POST 把 web-view 导到 OrderResultURL(=后端 /pay/omg/return)
+  → /return 返回 text/html(不是 302)
+  → HTML 加载 uni.webview.1.5.8.js,触发 uni.webView.redirectTo
+  → web-view 被替换成原生页 /pages/payResult
+```
+
+### 3.2 后端改动
+
+**`application.yml` 两 key 拆分(照抄 newebpay 模式):**
+```yaml
+omg:
+  order-result-url: https://foodieapi.waimai-paotui.com/pay/omg/return   # OMG POST 到这里
+  front-result-url: https://your-h5-domain/#/pages/payResult             # 桥接页 H5 兜底(纯原生可占位)
+```
+
+**`OmgPayController.java`:**
+- 第 111 行后新增 `@Value("${omg.front-result-url}") private String frontResultUrl;`
+- 第 188-190 行(发 OrderResultURL)**不动**
+- 第 396-402 行 `sendRedirect` 整段替换为:`response.setContentType("text/html;charset=UTF-8"); response.getWriter().write(buildBridgeHtml(ddId, frontResultUrl));`
+- 新增 `buildBridgeHtml(ddId, frontResultUrl)`(服务端注入,**转义引号防 XSS**)
+- `/return` **不加** CheckMacValue 验签(不改状态,真相在 `/notify:277` 验签 + `/query:704` JWT 鉴权)
+- 把 `uni.webview.1.5.8.js` 放 `ruoyi-admin/src/main/resources/static/`(目录需新建;`ResourcesConfig` 已注册 `/static/**`),同源 HTTPS 提供
+
+### 3.3 桥接页 HTML(已修正对抗审视发现的 bug)
+
+> 设计稿原版 bug:手动按钮 `showBtn()` 只在 `UniAppJSBridgeReady` 回调里 → SDK 没加载就永不出现,原生用户卡空白 web-view。修正版把按钮放到**所有失败路径**都能触达,等待窗口 3s→6s 轮询。
+
+```html
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
+<title>支付完成</title>
+<style>body{font-family:-apple-system,sans-serif;text-align:center;padding:48px 20px;color:#333}
+#tip{font-size:16px;margin:16px 0}
+a{color:#1989fa;padding:10px 20px;border:1px solid #1989fa;border-radius:4px;text-decoration:none;display:inline-block;margin-top:12px}</style>
+</head>
+<body>
+<div id="tip">支付完成,正在返回…</div>
+<div id="fb" style="display:none"><a id="btn" href="javascript:void(0)">点此返回商家</a></div>
+<script src="https://foodieapi.waimai-paotui.com/static/uni.webview.1.5.8.js"></script>
+<script>
+(function(){
+  var DDID="__DDID__", FRONT="__FRONT_URL__", done=false, tries=0;
+  var nativeUrl="/pages/payResult?ddId="+encodeURIComponent(DDID);
+  var spaUrl=FRONT+(FRONT.indexOf("?")>=0?"&":"?")+"ddId="+encodeURIComponent(DDID);
+  function hasBridge(){return (typeof uni!=="undefined")&&uni.webView&&uni.webView.redirectTo;}
+  function goNative(){if(done)return true;if(hasBridge()){done=true;uni.webView.redirectTo({url:nativeUrl});return true;}return false;}
+  function showBtn(){
+    document.getElementById("tip").textContent="若未自动返回,请点下方按钮";
+    document.getElementById("fb").style.display="block";
+    document.getElementById("btn").onclick=function(){ if(!goNative()) location.href=spaUrl; };
+  }
+  document.addEventListener("UniAppJSBridgeReady",function(){ if(!goNative()) showBtn(); });
+  var iv=setInterval(function(){
+    if(goNative()){clearInterval(iv);} else if(++tries>=20){clearInterval(iv);showBtn();}
+  },300);
+})();
+</script>
+</body>
+</html>
+```
+(`__DDID__`/`__FRONT_URL__` 后端注入。i18n:过渡 shim 留极简文案,权威多语言放原生 `payResult.vue`。)
+
+### 3.4 适用与局限
+
+- ✅ **即时支付(信用卡)**:秒回原生页
+- ❌ **ATM/超商/BarcodeATM/AFTEE**:OMG 不触发 OrderResultURL → 桥接页永不加载 → 这部分用户必须另走轮询。若想让取号也"主动回跳",还得**再做一个 `ClientRedirectURL` 桥接页**(同套路),复杂度翻倍且仍是两套体验。
+
+---
+
+## 4. 方案 B:轮询驱动统一结果页(推荐)
+
+### 4.1 机制
+
+把 OMG 收银台所在的 `<web-view>` **当黑盒**,**原生侧靠轮询后端接口驱动一切**,完全不依赖 OMG 的浏览器端回跳(不接 `OrderResultURL` / `ClientRedirectURL`)。web-view 宿主页 `omgCheckout.vue` 是原生页,web-view 是它的子视图 —— 宿主页的 JS 一直在跑,可以后台轮询。
+
+### 4.2 统一轮询逻辑(`omgCheckout.vue` 与 `payResult.vue` 共用)
+
+```
+每 ~2.5s(退避 2s/3s/5s/8s…,最多 ~30s):
+  q = POST /pay/omg/query              → payStatus(0/1/2;且自愈:/notify 丢了会主动问 OMG)
+  if payStatus == 1 → 进 payResult(成功)          ← 信用卡付完 / ATM 缴完款都走这
+  if payStatus == 2 → 进 payResult(失败)
+  # payStatus==0 时查取号:
+  info = GET /pay/omg/paymentInfo/{orderid}
+  if info 非空 → 进 payResult(取号页:虚帐/缴费码 + 期限 + 倒计时)   ← ATM/超商取号后
+  else 继续轮询(信用卡待付 / ATM 还没取号)
+```
+
+**对所有方式行为一致:**
+- **信用卡**:payStatus `0→1`,永不显示取号 → 直接成功页
+- **ATM/超商**:payStatus 一直 `0`,取号后 `info` 出现 → 取号页(用户记下码、关 App 去线下缴款);**日后重开 App**,订单详情/`payResult` 再轮询 → payStatus `→1` → 成功页
+
+### 4.3 `payResult.vue` 统一结果页(按状态渲染)
+
+| 状态 | 显示 |
+|------|------|
+| 取号信息已到(ATM/超商,未付款) | 银行代码/虚帐 或 缴费码 + 期限 + 倒计时 + "请于期限前缴款" |
+| payStatus=1 | 支付成功(任何方式) |
+| payStatus=2 | 支付失败,可重试 |
+| 确认中 | "支付结果确认中,可稍后在订单列表查看" |
+
+### 4.4 后端改动(基本就绪,反而要"减负")
+
+后端**基本不用新增**,反而可以**删掉桥接页相关的一切**:
+- `POST /pay/omg/query`(`:704`,既查又自愈)✅ 已有
+- `GET /pay/omg/paymentInfo/{orderid}`(`:477`,取号信息)✅ 已有
+- `POST /pay/omg/notify`(`:242`,真相源)✅ 已有
+- `POST /pay/omg/paymentInfo`(`:412`,取号服务端回调)✅ 已有 —— ATM/超商取号靠它,**保留**
+- **不再需要** `OrderResultURL` → `create()` 第 188-190 行可去掉;`/return` 端点、`uni.webview.1.5.8.js` 托管、桥接页 HTML 全部不用
+- `ClientRedirectURL` 也不用接(取号检测靠轮询 `paymentInfo`)
+
+### 4.5 适用
+
+✅ **所有方式一套逻辑**:信用卡、ATM、超商、BarcodeATM 统一。真相始终以 `/pay/omg/notify` + DB `payStatus` 为准,绝不凭页面跳转判断成功。
+
+---
+
+## 5. 方案对比
+
+| 维度 | A 桥接页 | B 轮询驱动(推荐) |
+|------|---------|------------------|
+| 信用卡回 App 速度 | 秒回(`redirectTo`) | 2-3s(轮询发现 payStatus=1) |
+| ATM/超商 | **不触发,须另做 `ClientRedirectURL` 桥接 → 两套体验** | 取号页 + 日后重开看结果,**同一套逻辑** |
+| 统一性 | 信用卡/延期割裂 | **所有方式一套** |
+| 后端工作量 | 加 config 两 key + `/return` 渲染 + `buildBridgeHtml` + 托管 SDK + 建 static 目录 | **基本零新增**(去掉 OrderResultURL 接线即可) |
+| 前端工作量 | 宿主页 + 结果页 + 注册路由 + 桥接依赖 JSBridge ready | 宿主页 + 结果页(统一轮询逻辑),无 JSBridge |
+| 可靠性 | 依赖 OMG 前景回跳(推断未实测)+ JSBridge 时序 + SDK 可达 | 只依赖后端接口(已实现 + 自愈) |
+| 残留风险 | i18n / 静态目录 / CSP / iOS WKWebView 脚本加载 | 轮询打 OMG `/query`(注意限流,用退避) |
+
+## 6. 推荐与决策
+
+**用方案 B。** 信用卡少 2-3 秒"秒回感",是唯一代价;收益是**全方式统一 + 少一大半代码 + 不踩 JSBridge/SDK/iOS 一系列坑**,且最不受"OMG=绿界"未实测推断的影响。
+
+**何时选 A:** 若产品极度在意信用卡的"秒回"体验、且能接受 ATM/超商另做一套 `ClientRedirectURL` 桥接(复杂度翻倍、仍是两套体验)。即使选 A,**ATM/超商部分也必须叠加 B 的轮询**,所以 A 实际是"B + 信用卡桥接增强"。
+
+> 实务上最常见的落地就是 **B 为主,A 作为信用卡的可选增强**(若日后想要秒回感再叠加,互不冲突)。
+
+---
+
+## 7. 客户端改动(uni-app,不在工作区,需 App 端做)
+
+**两方案共用:**
+- `pages.json` 注册结果页(不注册则 `redirectTo` / `navigateTo` 静默失败,最易踩的坑)
+- 路由建议**复用 newebpay 已有的 `/pages/payResult`**(按 `payType` 分支),省一个页面 + 一份 i18n
+
+**方案 B 额外(推荐做的全部):**
+- `omgCheckout.vue`(web-view 宿主):加 `onShow` + `setInterval` 跑 §4.2 统一轮询;终态时 `redirectTo` 到 `payResult` 并停轮询
+- `payResult.vue`:`onLoad` 拿 `ddId` → 继续跑 §4.2 轮询 → 按 §4.3 状态表渲染
+- 注意 `/query` 有 `@RepeatSubmit(2s)`,轮询间隔 ≥2s 并退避
+
+**方案 A 额外(若选 A):**
+- `omgCheckout.vue` 需确认收银台确实在 `<web-view>` 内(不是 `plus.runtime.openURL` 开外部浏览器,否则桥接前提崩塌)
+- 桥接页加载的 SDK 与 `/return` 同源 HTTPS
+
+## 8. 待验证假设 + 测试清单
+
+**上线前必做(OMG stage):**
+- [ ] 用测试卡支付,抓 `OrderResultURL` 实际 HTTP 方法(POST/GET)、是否在**支付失败**时也触发(影响方案 A 的 UX 判断;方案 B 不受影响)
+- [ ] (方案 A)`GET /static/uni.webview.1.5.8.js` 返回 200 + `application/javascript`
+- [ ] 真机 iOS 跑通(不止 Android)
+
+**方案 B 测试:**
+- [ ] 信用卡:`omgCheckout` → 支付 → 轮询 `/query` 得 payStatus=1 → `payResult` 成功
+- [ ] ATM:选 ATM → 取号 → 轮询 `/paymentInfo` 得 `info` → `payResult` 取号页 → 关 App →(stage SimulatePaid)→ 重开 → payStatus=1 → 成功
+- [ ] 漏单:禁 `/notify` → `/query` 经 `reconcileByQuery` 自愈到 payStatus=1
+- [ ] 幂等:同时 `/notify` 与轮询 → 只核销一次
+- [ ] 退避:确认轮询不超 OMG 限流
+
+**方案 A 额外测试:**
+- [ ] `/return` 返回 200 `text/html`、无 `Location` 头、连点不链式(死循环回归)
+- [ ] 禁掉 SDK → 6s 后显示手动按钮,不空白
+- [ ] `/create` 发出的 `OrderResultURL` = `.../pay/omg/return`
+
+## 9. 关键文件与行号
+
+| 位置 | 作用 | 方案 |
+|------|------|------|
+| `OmgPayController.java:188-190` | `create()` 发 `OrderResultURL` | A 保留 / B 可去掉 |
+| `OmgPayController.java:378-403` | `/pay/omg/return` | A 改渲染 HTML / B 不用 |
+| `OmgPayController.java:242 / :277` | `/notify` 服务端回调 + 验签(真相源) | 两方案都不动 |
+| `OmgPayController.java:412` | `/paymentInfo` 取号服务端回调 | 两方案都保留(ATM/超商取号) |
+| `OmgPayController.java:477-502` | `GET /paymentInfo/{orderid}` 取号信息查询 | **B 轮询目标** |
+| `OmgPayController.java:704-748` | `POST /query` 既查又自愈 | **B 轮询目标** |
+| `application.yml:40-49` | `omg:` 配置段 | A 拆两 key / B 可去 OrderResultURL |
+| `NewebpayPayController.java:285-307` | newebpay `/return` 302 两 key 模式 | A 的同构参考 |
+| `ResourcesConfig.java:46` | `/static/**` 映射 | 仅 A 需建 static 目录放 SDK |