frontend-integration.md 23 KB

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=CreditUnionPay=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,打开 <web-view> 加载 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.htmlstatic/,作为 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

{
  "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

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
  <title>跳转支付中</title>
  <style>
    body { text-align:center; padding:60px 20px; font-family:-apple-system,sans-serif; color:#888;background:#fff }
    .dots:after { content:'...'; animation:d 1s steps(1) infinite }
    @keyframes d { 50%{content:''} }
  </style>
</head>
<body>
  <div id="tip">正在跳转至支付页面<span class="dots"></span></div>
  <form id="payForm" method="post" action=""></form>
  <script>
    (function () {
      var qs = location.search.substring(1);
      if (!qs) { document.getElementById('tip').innerText = '参数缺失'; return; }
      var params = {};
      qs.split('&').forEach(function (kv) {
        var i = kv.indexOf('=');
        if (i > -1) params[decodeURIComponent(kv.slice(0, i))] = decodeURIComponent(kv.slice(i + 1));
      });
      var gateway = params.gatewayUrl;
      if (!gateway) { document.getElementById('tip').innerText = '缺少 gatewayUrl'; return; }
      var form = document.getElementById('payForm');
      form.action = gateway;
      Object.keys(params).forEach(function (k) {
        if (k === 'gatewayUrl') return;          // gatewayUrl 仅作 action,不作表单字段
        var el = document.createElement('input');
        el.type = 'hidden'; el.name = k; el.value = params[k];
        form.appendChild(el);
      });
      form.submit();
    })();
  </script>
</body>
</html>

4.2 发起支付页(点「去支付」)

// 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)
  });
}
<!-- pages/pay/omgCheckout.vue —— 只放一个 web-view -->
<template>
  <web-view :src="src"></web-view>
</template>
<script>
export default {
  data: () => ({ src: '' }),
  onLoad(opt) {
    // 把拼好的 query 透传给本地 omgPay.html
    this.src = '/static/omgPay.html?' + decodeURIComponent(opt.pay);
  }
}
</script>

注意: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 的 <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 页面:

/pages/OrderList/paySuccess/paySuccess?ddId=<订单号>

Bridge 成功属于 uni-app 内部路由,不会触发 plus.runtime.argumentsnewintent;只有进入 Scheme 兜底时才会出现这些 App 唤起事件。

// 支付成功页面加载后,从链接参数取得 ddId 并开始查询。

原生订单详情页(回来后)轮询确认状态

// 信用卡/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 参数

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.vueonReadymethods 中加入以下代码:

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_loadedbridge_redirect_startscheme_fallback_start 后最后一条生命周期日志,以及同一时段全部 [OMG-APP] 日志。
  5. 日志不得包含 token、CheckMacValue、HashKey、HashIV、完整订单号或银行卡资料;发现后先删除敏感内容再传给后端排查。

4.4.4 日志判读

日志表现 失败边界
没有 [OMG-RETURN] page_loaded OMG 没有进入 /pay/omg/result,或 Safari 检查器选错 WebView
bridge_environmentplus=true,随后出现 bridge_redirect_start 并进入结果页 App 内 Bridge 回跳成功;不会出现 newintent,属于正常行为
出现 bridge_environmentplus/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_runtimeargument.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.ymlomg.* 配置,前端要把自己页面的线上地址给后端配置

配置项 含义 需要的值(示例)
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=CreditUnionPay=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