适用端:客户 App(uni-app) | 后端分支:OmgPayController 已完成 | 日期:2026-08-14
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,只需要把用户「跳过去」再「接回来」。
[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来判断,因为服务端回调可能晚到几秒。
| # | 事项 | 在哪做 |
|---|---|---|
| 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 |
baseURL:前端约定的后端网关地址(如
https://api.xxx.com),下面路径在其后拼接。 所有需登录接口:Header 必须带token(用户 JWT)。
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 验签失败。前端只负责原样透传。
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 验签通过也不等于可以直接宣称已付款。
POST /pay/omg/queryApp 返回后用 Header token 和 JSON {"orderId":"..."} 调用。接口只查询该订单唯一有效支付尝试;若成功回调丢失,会向 OMG 查询并同步本地支付与订单状态。
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 |
以下为参考代码,需在 uni-app 工程内按实际路由/请求封装适配。
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>
// 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,符合要求。
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.arguments 或 newintent;只有进入 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);超过仍未成功提示「支付结果确认中,请稍后在订单列表查看」。
后端中转页会在浏览器控制台输出统一前缀 [OMG-RETURN],覆盖页面加载、Bridge 就绪与环境、Bridge 跳转、Scheme 兜底、手动触摸/点击、页面可见性和脚本异常。日志对象已序列化为 JSON,不再只显示 [object Object]。App 端需要按下面代码输出 [OMG-APP],两组日志应在同一次支付中一起收集。
所有日志只保留 Scheme 的协议/主机/路径、参数名和订单号后四位,不打印 token、CheckMacValue、OMG 表单或完整订单号。
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 携带同样的脱敏参数摘要。
omgCheckout.vue:支付子 WebView 生命周期App-vue 的 <web-view> 在 App 平台是父页面下的子 WebView,不要依赖组件的 @load/@error。在现有 omgCheckout.vue 的 onReady 和 methods 中加入以下代码:
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
}
}
}
OMG-APP。OMG-RETURN。bridge_redirect_start 后直接进入结果页;若中转页停留超过 3 秒,再点击一次“返回 App”。[OMG-RETURN] page_loaded 到 bridge_redirect_start 或 scheme_fallback_start 后最后一条生命周期日志,以及同一时段全部 [OMG-APP] 日志。| 日志表现 | 失败边界 |
|---|---|
没有 [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 执行环境。
payStatus(订单 + 流水)| 值 | 含义 |
|---|---|
| 0 | 未支付 |
| 1 | 已支付 |
| 2 | 已退款(订单)/ 失败(流水) |
| 3 | 已退款(流水) |
payType(OMG 回覆的付款方式)| payType | 名称 | 即时/延期 | 可自动退款 |
|---|---|---|---|
Credit_CreditCard |
信用卡(含 Apple Pay;银联已隐藏) | 即时 | ✅ |
当前开发测试范围只有信用卡和 Apple Pay,不存在延期支付历史交易兼容。
后端这几个 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.appScheme 作为外部浏览器和 Bridge 异常时的兜底。
CheckMacValue,改一个字符 OMG 验签就失败。payStatus 确认。window.open、不能 iframe 嵌套。MerchantTradeDate 含 / 和空格,不编码会截断。MerchantTradeNo),后端按 OMG 交易号 tradeNo 幂等,前端重复点「支付」不会重复扣款,但仍建议按钮防抖(后端已加 1s @RepeatSubmit)。ChoosePayment=Credit、UnionPay=2,不实现延期支付入口。所有面向用户的文字必须走 $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。