# OMG(歐買尬/FunPoint)支付 — 前端接入文档 **适用端**:客户 App(uni-app) | **后端分支**:`OmgPayController` 已完成 | **日期**:2026-08-14 --- ## 0. 先搞懂一件事:OMG 没有「支付 App」 OMG(欧买尬/FunPoint)是**网页托管式支付网关**(AIO = All-In-One 收银台),跟蓝新/绿界同类。它的设计就是**没有独立 App**——付款发生在 **OMG 的网页收银台**上。 | 支付 | 有没有 App | 付款在哪发生 | 前端怎么触发 | |------|-----------|-------------|------------| | LINE Pay | 有 LINE App | 唤起 LINE App 内 | deep link 唤起 | | **OMG / FunPoint** | **没有 App** | **OMG 的网页收银台** | **form post 跳网页** | 后端固定使用 `ChoosePayment=Credit`、`UnionPay=2`,OMG 收银台只提供 **信用卡与 Apple Pay**。**前端不需要集成任何支付 SDK,只需要把用户「跳过去」再「接回来」。** --- ## 1. 整体流程(必读) ``` [App: 订单详情页 点"去支付"] │ │ ① POST /pay/omg/create JSON {"orderId":"..."} (Header: token) ▼ [后端] 校验订单 → 组参+签名 → 返回 form 字段(含 gatewayUrl) │ │ 返回 { data: { gatewayUrl, MerchantID, MerchantTradeNo, CheckMacValue, ... } } ▼ [App] ② 把 form 字段拼到 URL,打开 加载 omgPay.html │ │ omgPay.html 自动 submit 隐藏表单 → 跳转 ▼ [OMG 收银台网页] 用户使用信用卡或 Apple Pay 付款 │ │ ③ 付款后 OMG 把 web-view 跳回 OrderResultURL │ 后端 /pay/omg/result 返回安全桥接页 → App Link ▼ [App: 支付结果页] 读 ddId → 查订单状态 → 展示结果 ─── 与此同时(服务端,前端无感)─── OMG POST 后端 /pay/omg/notify → 验签+核销 → 订单 payStatus 置 1 + 推送 (订单状态以这条服务端回调为准,不要只信网页跳转) ``` > **关键原则**:网页跳回结果页 ≠ 一定支付成功。结果页必须**再查一次订单 `payStatus`** 来判断,因为服务端回调可能晚到几秒。 --- ## 2. 前端要做的事(总览) | # | 事项 | 在哪做 | |---|------|--------| | 1 | 放一个 `omgPay.html` 到 `static/`,作为 web-view 收银台跳转页 | uni-app 工程 | | 2 | 发起支付:调 `/pay/omg/create` → 拼 URL → 打开 web-view | 发起支付页 | | 3 | 支付结果页:接收 `orderId` → 调 `/pay/omg/query` → 展示成功/失败/确认中 | 支付结果页 | | 4 | 不实现 ATM/超商取号及延期支付返回入口 | 无新增页面 | | 5 | (可选)取消订单退款:调 `/pay/omg/refund` | 订单取消逻辑 | | 6 | 告诉后端两个前端地址(见 §6 配置依赖) | 沟通 | | 7 | 所有面向用户文字走 i18n 4 语言 | zh/tw/en/vi | | 8 | iOS 联调时同时收集 `[OMG-RETURN]` 与 `[OMG-APP]` 日志 | HBuilderX + Safari Web Inspector | --- ## 3. 接口清单 > baseURL:前端约定的后端网关地址(如 `https://api.xxx.com`),下面路径在其后拼接。 > 所有需登录接口:**Header 必须带 `token`(用户 JWT)**。 ### 3.1 发起支付 — `POST /pay/omg/create` **用途**:生成一次 OMG 收银台跳转所需的全部参数。 | 项 | 内容 | |----|------| | Method | `POST` | | Header | `token: <用户JWT>` | | 入参 | JSON:`{"orderId":"订单号"}` | | 鉴权 | 登录用户 + 必须是订单本人 | **请求示例** ``` POST {baseURL}/pay/omg/create Header: token: eyJhbGciOi... Content-Type: application/json {"orderId":"DD202608060001"} ``` **成功返回**(`code=200`,取 `data`) ```json { "code": 200, "msg": "操作成功", "data": { "status": "CREATED", "gatewayUrl": "https://payment.funpoint.com.tw/Cashier/AioCheckOut/V5", "formFields": { "MerchantID": "3xxxxxx2", "MerchantTradeNo": "OMG20260806143012xxx", "MerchantTradeDate": "2026/08/06 14:30:12", "PaymentType": "aio", "TotalAmount": "350", "TradeDesc": "food order DD202608060001", "ItemName": "order DD202608060001", "ReturnURL": "https://api.xxx.com/pay/omg/notify", "ChoosePayment": "Credit", "UnionPay": "2", "EncryptType": "1", "InvoiceMark": "N", "NeedExtraPaidInfo": "Y", "OrderResultURL": "https://api.xxx.com/pay/omg/result", "CheckMacValue": "A1B2C3D4E5F6...(64位)" } } } ``` **常见错误返回** | code / msg | 含义 | 前端处理 | |-----------|------|---------| | 请先登录 | token 缺失/失效 | 跳登录 | | 无权操作该订单 | 非本人订单 | 提示即可 | | 订单已支付 | 已付过 | 刷新订单状态 | | 订单已取消,不可重新支付 | state=4 | 引导重下单 | | 该门店暂不支持线上支付 | 门店未开通 OMG | 提示「该门店暂不支持在线支付」 | | 请求过于频繁 | 1 秒内重复点 | 防抖 | > ⚠️ **绝对不能修改 `data` 里任何字段的值**(尤其 `CheckMacValue`)。任何改动都会导致 OMG 验签失败。前端只负责**原样透传**。 --- ### 3.2 支付结果返回 — `POST /pay/omg/result` **不是前端直接调的接口**。付款完成后 OMG Client POST 到 `/pay/omg/result`,后端返回 no-store 安全桥接页并唤醒 App,不修改支付状态。 ``` com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess?ddId=DD202608060001 ``` App 打开后必须调用 `/pay/omg/query` 确认结果;`/result` 验签通过也不等于可以直接宣称已付款。 --- ### 3.3 支付状态查询 — `POST /pay/omg/query` App 返回后用 Header `token` 和 JSON `{"orderId":"..."}` 调用。接口只查询该订单唯一有效支付尝试;若成功回调丢失,会向 OMG 查询并同步本地支付与订单状态。 --- ### 3.4 退款(取消已支付订单)— `POST /pay/omg/refund` **用途**:用户取消已支付的信用卡/Apple Pay 订单时退款。 | 项 | 内容 | |----|------| | Method | `POST` | | Header | `token: <用户JWT>` | | 入参 | query:`orderid` = ddId | | 鉴权 | 订单本人 | **请求** ``` POST {baseURL}/pay/omg/refund?orderid=DD202608060001 Header: token: eyJ... ``` **返回** | code=200 | 信用卡/Apple Pay 退刷成功,`pos_order.pay_status` 置 2 | --- ## 4. uni-app 参考实现 > 以下为参考代码,需在 uni-app 工程内按实际路由/请求封装适配。 ### 4.1 收银台跳转页 `static/omgPay.html` 放到 `static/` 目录(web-view 可加载本地文件)。它从 URL query 读取后端返回的所有字段,自动 submit 到 `gatewayUrl`。 ```html 跳转支付中
正在跳转至支付页面
``` ### 4.2 发起支付页(点「去支付」) ```js // 1. 调发起接口 uni.request({ url: baseURL + '/pay/omg/create', method: 'POST', header: { token: uni.getStorageSync('token') }, data: { orderid: ddId }, // 也可用 ?orderid= 拼在 url 上 success: (res) => { const d = res.data; if (d.code !== 200) { uni.showToast({ title: d.msg, icon: 'none' }); return; } goCheckout(d.data); // data = form 字段 } }); // 2. 拼 query 打开 web-view 收银台 function goCheckout(form) { // ⚠️ 每个值都要 encodeURIComponent,否则 / 空格 = 等字符会破坏 URL const query = Object.keys(form) .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(form[k])) .join('&'); uni.navigateTo({ url: '/pages/pay/omgCheckout?pay=' + encodeURIComponent(query) }); } ``` ```html ``` > 注意:iOS 上 web-view 不可另开窗、收银台不可被 iframe 套——本方案是完整页 web-view,符合要求。 ### 4.3 支付结果页 `pages/pay/omgResult` 后端 `/pay/omg/result` 返回桥接页并打开 `pages/OrderList/paySuccess/paySuccess`。桥接页通过匿名只读地址 `/pay/omg/bridge.js` 加载后端自托管的 `uni.webview.1.5.8.js`:在 App 的 `` 内等待 `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` 页面: ```text /pages/OrderList/paySuccess/paySuccess?ddId=<订单号> ``` Bridge 成功属于 uni-app 内部路由,不会触发 `plus.runtime.arguments` 或 `newintent`;只有进入 Scheme 兜底时才会出现这些 App 唤起事件。 ```js // 支付成功页面加载后,从链接参数取得 ddId 并开始查询。 ``` 原生订单详情页(回来后)**轮询确认状态**: ```js // 信用卡/Apple Pay:查询唯一有效支付尝试 const poll = (ddId) => { uni.request({ url: baseURL + '/pay/omg/query', method: 'POST', header: { token: uni.getStorageSync('token') }, data: { orderId: ddId }, success: (res) => { const d = res.data.data; if (d.status === 'PAID') { showSuccess(); } else if (d.status === 'FAILED') { showFail(); } else { setTimeout(() => poll(ddId), 2000); } } }); }; ``` > 轮询建议:每 2s 一次,最多 15 次(30s);超过仍未成功提示「支付结果确认中,请稍后在订单列表查看」。 ### 4.4 iOS / uni-app App 唤起全链路诊断日志 后端中转页会在浏览器控制台输出统一前缀 `[OMG-RETURN]`,覆盖页面加载、Bridge 就绪与环境、Bridge 跳转、Scheme 兜底、手动触摸/点击、页面可见性和脚本异常。日志对象已序列化为 JSON,不再只显示 `[object Object]`。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 的 `` 在 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 信用卡支付。正常情况会在 `bridge_redirect_start` 后直接进入结果页;若中转页停留超过 3 秒,再点击一次“返回 App”。 4. 导出从 `[OMG-RETURN] page_loaded` 到 `bridge_redirect_start` 或 `scheme_fallback_start` 后最后一条生命周期日志,以及同一时段全部 `[OMG-APP]` 日志。 5. 日志不得包含 token、CheckMacValue、HashKey、HashIV、完整订单号或银行卡资料;发现后先删除敏感内容再传给后端排查。 #### 4.4.4 日志判读 | 日志表现 | 失败边界 | |----------|----------| | 没有 `[OMG-RETURN] page_loaded` | OMG 没有进入 `/pay/omg/result`,或 Safari 检查器选错 WebView | | 有 `bridge_environment` 且 `plus=true`,随后出现 `bridge_redirect_start` 并进入结果页 | App 内 Bridge 回跳成功;不会出现 `newintent`,属于正常行为 | | 出现 `bridge_environment` 但 `plus/nvue/uvue` 均为 `false` | 当前页面不在 uni-app App Bridge 环境,后端会进入 Scheme 兜底 | | 只有 `bridge_ready_timeout`,没有任何 `bridge_ready/bridge_environment` | 先直接访问 `/pay/omg/bridge.js`,必须返回 HTTP 200、`application/javascript` 和 SDK 内容;若返回 JSON/401,Bridge 不会执行 | | 出现 `scheme_fallback_start`,外部浏览器可打开同一 Scheme,但 App 内页面仍停留 | 当前子 WebView 未把同 App Scheme 交给 iOS;继续检查 Bridge SDK 是否返回 200 及 `bridge_environment` 日志 | | 中转页出现 `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`(订单 + 流水) | 值 | 含义 | |----|------| | 0 | 未支付 | | 1 | 已支付 | | 2 | 已退款(订单)/ 失败(流水) | | 3 | 已退款(流水) | ### `payType`(OMG 回覆的付款方式) | payType | 名称 | 即时/延期 | 可自动退款 | |---------|------|----------|-----------| | `Credit_CreditCard` | 信用卡(含 Apple Pay;银联已隐藏) | 即时 | ✅ | > 当前开发测试范围只有信用卡和 Apple Pay,不存在延期支付历史交易兼容。 --- ## 6. 配置依赖(需前端 ↔ 后端约定) 后端这几个 URL 来自 `application.yml` 的 `omg.*` 配置,**前端要把自己页面的线上地址给后端配置**: | 配置项 | 含义 | 需要的值(示例) | |--------|------|----------------| | `omgpay.order-result-url` | OMG 付款完成 Client POST 地址 | `https://api.xxx.com/pay/omg/result` | | `omgpay.app-return-url` | Bridge 不可用时唤醒 App 的固定 Scheme,同时用于推导固定 App 内路由 | `com.twanmsdyh.app://pages/OrderList/paySuccess/paySuccess` | | `omgpay.notify-url` | 服务端结果回调(前端不用管) | `https://api.xxx.com/pay/omg/notify` | > App 必须在 `pages.json` 注册 `/pages/OrderList/paySuccess/paySuccess`,同时注册 `com.twanmsdyh.app` Scheme 作为外部浏览器和 Bridge 异常时的兜底。 --- ## 7. 注意事项 / 易踩坑 1. **不要修改任何 form 字段值**——尤其 `CheckMacValue`,改一个字符 OMG 验签就失败。 2. **结果不能只信网页跳转**——OMG 网页跳回 ≠ 服务端回调已完成,必须轮询 `payStatus` 确认。 3. **iOS web-view 不可另开窗**——收银台必须是完整页 web-view,不能 `window.open`、不能 iframe 嵌套。 4. **query 传参必须 encodeURIComponent**——`MerchantTradeDate` 含 `/` 和空格,不编码会截断。 5. **多次发起**——同一订单可重复发起(每次新 `MerchantTradeNo`),后端按 OMG 交易号 `tradeNo` 幂等,前端重复点「支付」不会重复扣款,但仍建议按钮防抖(后端已加 1s `@RepeatSubmit`)。 6. **付款方式固定**——表单只使用 `ChoosePayment=Credit`、`UnionPay=2`,不实现延期支付入口。 7. **退款范围**——当前订单只有信用卡/Apple Pay。 --- ## 8. i18n 提示(项目硬性要求) 所有面向用户的文字必须走 `$t()`,4 语言(zh/tw/en/vi)同步添加,禁止硬编码中文。本功能涉及的文案 key 建议: | key | 中文 | 用途 | |-----|------|------| | `payOmg.goPay` | 去支付 | 发起按钮 | | `payOmg.redirecting` | 正在跳转至支付页面 | omgPay.html / 跳转中 | | `payOmg.success` | 支付成功 | 结果页 | | `payOmg.fail` | 支付失败 | 结果页 | | `payOmg.confirming` | 支付结果确认中 | 轮询中 | | `payOmg.storeNotSupported` | 该门店暂不支持在线支付 | 门店未开通 | > key 必须用有意义英文(驼峰),加到 4 个语言文件**对应层级对象内**(参考项目 CLAUDE.md 的 i18n 规范)。 --- ## 附:后端接口文件对照 | 前端动作 | 后端方法 | 文件 | |---------|---------|------| | 发起支付 | `OmgPayController.create` | `app/pay/OmgPayController.java` | | 支付结果桥接页 | `OmgPaymentReturnController.result` / `back` | `app/omgpay/OmgPaymentReturnController.java` | | 状态查询 | `OmgPaymentController.query` | `app/omgpay/OmgPaymentController.java` | | 退款 | `OmgPaymentController.refund` | 同上 | | 服务端回调(前端无感) | `OmgPaymentController.notify` | 同上 | 后端契约详见 `specs/016-omg-payment/contracts/api.md`。