嵌入H5支付方案(服务号链接支付、微信小程序原生支付)
统一支付方案详解(案例版)
下面只讲当前主流程:requestMiniProgramPay → /bank/pay/pay → 按环境唤起支付 → 回跳 → 订单轮询确认。
一、方案要解决什么问题
护理患者端 H5 跑在两个容器里:
| 容器 | 典型场景 | 支付能力 |
|---|---|---|
| 微信公众号内浏览器 | 用户从公众号菜单进入 | 只能跳外部收银台 URL |
| 微信小程序 WebView | 用户从小程序打开 H5 | 可 navigateTo 到小程序原生页调起微信支付 |
如果各写一套支付逻辑,业务页会重复、环境判断散落、结果确认不一致。当前方案的做法是:
- 业务页只调一个函数
requestMiniProgramPay({ orderNo }) - 后端只提供一个接口
POST /bank/pay/pay,用source区分返回payUrl还是微信支付参数 - 小程序场景不直接在 H5 里调微信支付,而是把参数交给宿主小程序的
thirdPay页面 - 支付平台前端回调不作为最终成功依据,以业务订单
orderStatus轮询为准
二、参与角色与职责
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ 业务页面 │────▶│ miniProgramPay │────▶│ 后端支付网关 │
│ confirmOrder │ │ (统一支付模块) │ │ /bank/pay/pay│
│ orderDetail │ └────────┬─────────┘ └──────┬──────┘
└─────────────┘ │ │
│ source=1 │ 异步 notify
▼ ▼
┌──────────────────┐ ┌─────────────┐
│ 微信收银台 URL │ │ 订单服务 │
└──────────────────┘ │ orderStatus │
│ └──────▲──────┘
│ source=2 │ 轮询
▼ │
┌──────────────────┐ ┌─────────────┐
│ 小程序 thirdPay │────▶│ payResult │
│ + payBridge 中间页│ │ 结果确认页 │
└──────────────────┘ └─────────────┘
| 模块 | 文件 | 职责 |
|---|---|---|
| 统一支付 | miniProgramPay.js | 环境识别、调 API、分支唤起、状态存储 |
| 下单支付 | confirmOrder/index.vue | 创建订单 → 调支付 |
| 补支付 | orderDetail/index.vue | 待支付/超时费/子费用 → 调支付 |
| 回跳拦截 | router/index.js | 检测 payStatus 强制进结果页 |
| 中间页 | payBridge/index.vue | 小程序回跳后的过渡页 |
| 结果确认 | payResult/index.vue | 解析回跳 + 轮询订单 |
三、完整时序(下单支付)
阶段 1:创建订单
async createOrderHandle() {
if (this.orderSubmitting) {
return
}
// ...
const uuidRes = await service.getUuid({})
const data = {
// ... 地址、时间、就诊人、项目等
uuid: uuidRes.data,
source: '3',
signatureImage: this.signatureImage
}
const res = await service.createOrder(data)
要点:
- 先取 UUID 再下单:
GET /placeOrder/uuid,axios 拦截器把uuid写入请求头placeOrderUuid,防重复提交 orderSubmitting锁:防止用户连点「提交」- 创建成功拿到
orderNo/payAmount
阶段 2:判断是否跳过支付
if (this.isService || (hasPayAmount && payAmount === 0)) {
this.removeData()
this.$router.replace({ path: '/payResult', query: { orderNo } })
return
}
isService=true:免费/活动服务payAmount=0:零元单
两种都不调支付接口,直接进结果页,由结果页轮询确认订单状态。
阶段 3:调统一支付
await requestMiniProgramPay({
orderNo,
payMoney: this.getCurrentPayMoney(payAmount),
router: this.$router,
context: result,
contextLabel: 'createOrder data',
onSuccess: () => this.removeData()
})
requestMiniProgramPay 入参说明:
| 参数 | 必填 | 作用 |
|---|---|---|
orderNo | ✅ | 传给后端的 orderId |
payMoney | 否 | 仅透传给小程序 orderInfo,方便展示 |
context | 否 | 打日志用 |
onSuccess | 否 | 跳转支付成功后清下单草稿(localStorage/sessionStorage) |
四、统一支付模块内部细节
4.1 双重防重复锁
let payHandling = false
let payRequesting = false
| 锁 | 生效时机 | 释放时机 |
|---|---|---|
payRequesting | 调 /bank/pay/pay 期间 | API 返回后(若未进入跳转则释放) |
payHandling | 小程序 navigateTo 跳转期间 | navigate 成功/失败后 |
公众号 location.href 跳转后页面卸载,锁意义不大;小程序 navigateTo 是异步的,需要 payHandling 防止连点。
4.2 环境识别 getWechatHostSource
判定顺序(先快后慢):
1. window.__wxjs_environment === 'miniprogram'
或 UA 含 'miniprogram'
→ source = 2(小程序 WebView)
2. wx.miniProgram.getEnv(callback)
→ res.miniprogram ? 2 : 1
→ 500ms 超时兜底为 1(公众号)
3. 默认 source = 1(公众号 H5)
为什么需要 500ms 超时:getEnv 是异步回调,部分机型回调慢,不能一直等。
4.3 请求支付参数
return getWechatHostSource().then(source => {
const payData = { orderId: orderNo, source }
return service.miniProgramPay(payData).then(payRes => {
savePayResponse(payRes)
return handleMiniProgramPayResponse(payRes, source, orderNo, options)
})
请求体固定两个字段:
{ "orderId": "ORDxxx", "source": 1 }
响应成功判定兼容 code 为数字 200 或字符串 '200',甚至 code 为空也当成功:
function isSuccessResponse(payRes) {
if (!payRes) return false
if (payRes.code === undefined || payRes.code === null || payRes.code === '') {
return true
}
return String(payRes.code) === '200'
}
4.4 分支 A:公众号(source=1)
if (source === 1) {
const payData = payRes && payRes.data ? payRes.data : {}
const payUrl = typeof payData === 'string' ? payData : payData.payUrl
// ...
localStorage.setItem('miniProgramPayOrderNo', orderNo)
window.location.href = payUrl
细节:
data可以是对象{ payUrl: "..." }或直接是 URL 字符串- 跳转前把
orderNo写入localStorage,支付完成回跳时若 URL 没带订单号可兜底读取 - 整页跳走,后续流程由银行/微信收银台控制,回跳 URL 由后端配置
4.5 分支 B:小程序(source=2)
Step 1:解析微信支付五参数
后端响应可能有多种嵌套,前端做了兼容:
function getWechatPayParams(payRes) {
return payRes && (payRes.result || (payRes.data && payRes.data.result) || payRes.data)
}
function buildWechatRequestParams(payParams = {}) {
return {
timeStamp: String(payParams.timeStamp || payParams.timestamp || ''),
nonceStr: payParams.nonceStr,
package: payParams.package || payParams.packageValue,
signType: payParams.signType,
paySign: payParams.paySign
}
}
解析优先级:payRes.result → payRes.data.result → payRes.data
字段别名:timestamp/timeStamp、packageValue/package
缺任一必填字段 → Toast「微信支付参数不完整」,不跳转。
Step 2:拼小程序跳转 URL
function getPayBridgeH5Url(orderNo) {
const query = orderNo ? `?orderNo=${encodeURIComponent(orderNo)}` : ''
return `${getCurrentH5DocumentUrl()}#/payBridge${query}`
}
function buildMiniProgramPayUrl(payParams, orderNo, payMoney) {
const h5Url = encodeURIComponent(getPayBridgeH5Url(orderNo))
const payInfo = encodeURIComponent(JSON.stringify(payParams))
const orderInfo = encodeURIComponent(JSON.stringify({
orderId: orderNo,
payMoney: hasValue(payMoney) ? payMoney : ''
}))
return `/pages/index/thirdWeb?path=${h5Url}&action=thirdPay&payInfo=${payInfo}&orderInfo=${orderInfo}`
}
展开后的结构:
/pages/index/thirdWeb
?path=https://xxx/patienth5/#/payBridge?orderNo=ORD001 ← 支付完回跳的 H5 地址
&action=thirdPay ← 告诉小程序执行第三方支付
&payInfo={"timeStamp":"...","nonceStr":"...","package":"prepay_id=...","signType":"RSA","paySign":"..."}
&orderInfo={"orderId":"ORD001","payMoney":"128.00"}
Step 3:navigateTo 跳转
miniProgram.navigateTo({
url,
success() {
// 跳转成功,payHandling 释放
},
fail(err) {
Toast('支付调用失败')
}
跳转前还会:
localStorage.miniProgramPayOrderNo = orderNo- 若传了
onSuccess,写miniProgramPayCleanupOrderNo,支付成功后清草稿
小程序侧(本仓库外)需实现:
- 读
action=thirdPay - 解析
payInfo,调wx.requestPayment - 支付结束按约定拼
payStatus/payDesc/orderNo回跳path指定的 H5
五、支付回跳与路由拦截
5.1 payBridge 中间页
小程序支付唤起后,H5 先落到 #/payBridge?orderNo=xxx:
- 展示「正在唤起支付」
- 用户可点「查看支付结果」→
replace到/payResult
这是兜底:若小程序回跳带 payStatus 但用户没自动跳转,可手动进结果页。
5.2 全局路由守卫
router.beforeEach(async (to, from, next) => {
const miniProgramPayResult = getMiniProgramPayResultParams()
if (miniProgramPayResult.payStatus && to.path !== '/payResult') {
const query = { payStatus: miniProgramPayResult.payStatus }
if (miniProgramPayResult.orderNo) query.orderNo = miniProgramPayResult.orderNo
if (miniProgramPayResult.payDesc) query.payDesc = miniProgramPayResult.payDesc
next({ path: '/payResult', query })
return
}
无论用户落到哪个页面,只要 URL 带 payStatus,都强制进 /payResult。
避免支付完回跳到首页、订单列表等页面时丢失结果处理。
5.3 回跳参数解析
export function getMiniProgramPayResultParams() {
const searchParams = new URLSearchParams(window.location.search || '')
const hashParams = getHashSearchParams()
const payStatus = searchParams.get('payStatus') || hashParams.get('payStatus') || ''
const payDesc = searchParams.get('payDesc') || hashParams.get('payDesc') || ''
const orderNo = searchParams.get('orderNo') || hashParams.get('orderNo')
|| searchParams.get('orderId') || hashParams.get('orderId')
|| getMiniProgramPayOrderNo() || ''
return { payStatus, payDesc, orderNo }
}
兼容三种情况:
- 参数在
?payStatus=success(search) - 参数在
#/payResult?payStatus=success(hash) - URL 无订单号 → 读
localStorage.miniProgramPayOrderNo
进结果页后会 clearMiniProgramPayResultSearchParams() 清掉 URL 上的支付参数,避免刷新重复触发守卫。
六、结果确认:双层判定机制
这是本方案最核心的设计:前端回跳成功 ≠ 业务订单已支付。
6.1 两层状态
| 层级 | 数据来源 | 含义 |
|---|---|---|
| 前端层 | URL payStatus=success | 微信支付客户端返回成功 |
| 业务层 | orderDetail.orderStatus | 后端已收到 notify 并更新订单 |
后端 notify 有延迟时,会出现:前端 success,但 orderStatus 仍是 1(待支付)。
6.2 payResult 状态机
进入 payResult
│
├─ payStatus=fail ──▶ 显示失败文案,停止轮询
│
├─ payStatus=success ──▶ 写 sessionStorage「支付确认中」
│ 显示「支付成功,订单同步中」
│ 开始轮询 orderDetail
│
└─ 无 payStatus ──▶ 直接轮询 orderDetail(如零元单、公众号回跳无参数)
轮询参数:
maxPollCount: 12,
pollInterval: 10000,
即 10 秒一次,最多 12 次(约 2 分钟)。
轮询逻辑:
service.getOrderDetail({ orderNo: this.orderNo }).then(res=>{
const orderStatus = Number(statusValue)
if (this.isPaySuccessStatus(orderStatus)) {
this.markPaySuccess(orderStatus) // ≥3,真正成功
} else if (this.isPayPendingStatus(orderStatus)) {
// 仍是 1 或 14
if (this.isFrontendPaySuccess()) {
saveMiniProgramPayConfirming(this.orderNo, ...)
}
this.startPayPoll() // 继续等
} else {
// 已取消等异常态
this.paySuccess = false
clearMiniProgramPayConfirming(...)
}
订单状态分组:
const PAY_SUCCESS_STATUSES = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
const PAY_PENDING_STATUSES = [1, 14]
UI 文案随状态变化:
payResultText() {
if (this.isFrontendPayFail()) return this.urlPayDesc || '支付失败'
if (this.isFrontendPaySuccess() && !this.isPaySuccessStatus(this.currentOrderStatus))
return '支付成功,订单同步中'
if (this.isFrontendPaySuccess() && this.paySuccess)
return this.urlPayDesc || '支付成功'
if (this.payChecking) return '支付确认中'
return ORDER_STATUS_TEXT[this.currentOrderStatus] || '未知状态'
},
6.3 「支付确认中」防重复支付
前端 success 但订单仍 pending 时,写入 sessionStorage:
export function saveMiniProgramPayConfirming(orderNo, payResult = {}) {
const data = getPayConfirmingMap()
data[String(orderNo)] = {
payStatus: 'success',
payDesc: payResult.payDesc || '',
updatedAt: Date.now()
}
setSessionStorage(MINI_PROGRAM_PAY_CONFIRMING_KEY, JSON.stringify(data))
}
- Key:
sessionStorage.miniProgramPayConfirming - 结构:按订单号索引的 Map
- TTL:10 分钟(
MINI_PROGRAM_PAY_CONFIRMING_MAX_AGE)
用户在「订单同步中」回到 orderDetail 时:
const payConfirmingResult = getMiniProgramPayConfirming(orderNo)
this.payConfirming = !!payConfirmingResult && this.isPayPendingStatus(detail.orderStatus)
- 按钮变为「支付确认中」,禁止再次支付
- 暂停待支付倒计时
- 同样 10 秒轮询订单,直到
orderStatus ≥ 3
七、补支付场景(orderDetail)
与下单支付走同一套 requestMiniProgramPay,差异在触发条件:
| 场景 | 条件 | 按钮 |
|---|---|---|
| 待支付 | orderStatus=1 | 「支付费用」,10 分钟倒计时后订单取消 |
| 超时费 | orderStatus=14 | 「支付费用」,展示 overtimeAmountToPay |
| 子订单新增费用 | hasUnpaidChildrenOrder | 底部弹窗「去支付」 |
子费用弹窗逻辑:
openUnpaidChildrenPayPopup(detail = {}) {
if (!detail.hasUnpaidChildrenOrder) return
const fees = this.normalizeUnpaidChildrenFees(detail.unpaidFeeList)
const total = fees.reduce((sum, fee) => sum + Number(fee.amount || 0), 0)
this.unpaidChildrenPayOrderNo = detail.orderNo || this.id
this.unpaidChildrenPayMoney = this.formatMoney(total)
this.unpaidChildrenPayShow = true
},
费用类型:路程费(2)、医疗服务(3)、耗材(6)、上门服务费(8)。
八、本地存储一览
| 存储 | Key | 写入时机 | 用途 |
|---|---|---|---|
| localStorage | miniProgramPayOrderNo | 发起支付前 | 回跳时兜底订单号 |
| localStorage | miniProgramPayResponse | API 返回后 | 调试/排查最近一次响应 |
| localStorage | miniProgramPayCleanupOrderNo | 传了 onSuccess 时 | 标记支付成功后需清草稿 |
| sessionStorage | miniProgramPayConfirming | 前端 success 时 | 防重复支付,10 分钟 TTL |
| sessionStorage | order_his 等 9 个 key | 下单草稿 | 支付成功 onSuccess 后清理 |
支付成功清理草稿:
export function finishMiniProgramPaySuccess(orderNo) {
const cleanupOrderNo = getStorage(MINI_PROGRAM_PAY_CLEANUP_KEY)
if (cleanupOrderNo && cleanupOrderNo === String(currentOrderNo)) {
ORDER_DRAFT_KEYS.forEach(key => sessionStorage.removeItem(key))
removeStorage(MINI_PROGRAM_PAY_CLEANUP_KEY)
}
removeStorage(MINI_PROGRAM_PAY_ORDER_KEY)
}
九、错误处理矩阵
| 阶段 | 条件 | 用户提示 | 是否可重试 |
|---|---|---|---|
| 入参 | 无 orderNo | 未获取到订单号 | 否 |
| 防重 | 正在请求/跳转 | 静默忽略 | 等上次结束 |
| API | code 非 200 | alertMsg 或「获取支付参数失败」 | 是 |
| API | 网络/拦截器 400 | Toast(alertMsg) | 是 |
| 公众号 | 无 payUrl | 未获取到支付地址 | 是 |
| 小程序 | 五参数缺一 | 微信支付参数不完整 | 是 |
| 小程序 | 无 wx.miniProgram | 当前环境不支持小程序支付 | 否 |
| 小程序 | navigateTo 失败 | 支付调用失败 | 是 |
| 结果 | payStatus=fail | payDesc 或「支付失败」 | 是 |
| 结果 | success 但 2 分钟仍 pending | 「支付成功,订单同步中」停轮询 | 联系客服/刷新 |
axios 拦截器对 400/60001 会 Toast 并 reject 带 __toastShown,支付模块检测到后不再重复 Toast:
function showPayRequestError(err) {
if (err && err.__toastShown) return
Toast((err && (err.alertMsg || err.msg || err.message)) || '支付接口请求失败')
}
十、作为案例可提炼的设计模式
1. 统一门面(Facade)
业务页不关心环境,只调 requestMiniProgramPay。新增支付宝等渠道时,改模块内分支即可。
2. 宿主桥接(Host Bridge)
H5 在小程序 WebView 内不直接调 wx.requestPayment,而是通过 URL 参数把 payInfo 交给宿主小程序。这样 JSSDK 权限、商户配置都在小程序侧统一管理。
3. 乐观前端 + 悲观业务确认
- 前端:收到
payStatus=success先给用户正向反馈 - 业务:轮询
orderStatus才认定真正到账 - 中间态:「支付成功,订单同步中」+ 防重复支付
4. 路由级回跳兜底
全局守卫拦截 payStatus,不依赖业务页自己处理回跳,降低遗漏风险。
5. 多源参数兼容
search / hash / localStorage 三重兜底取 orderNo,适配不同回跳方式和 hash 路由。
十一、联调检查清单
H5 侧
- 公众号:
source=1能拿到payUrl并跳转 - 小程序 WebView:
source=2五参数完整,navigateTo成功 - 回跳 URL 带
payStatus、orderNo - 路由守卫能强制进
/payResult - 轮询 2 分钟内
orderStatus能从 1 变 3
小程序侧(本仓库外)
-
thirdWeb识别action=thirdPay - 正确解析
payInfo调wx.requestPayment - 成功/失败/取消分别回跳并带正确
payStatus
后端侧(本仓库外)
-
/bank/pay/pay按source返回不同结构 - 支付 notify 能及时更新
orderStatus - 公众号收银台回跳地址配置正确
如果你要把这份内容沉淀成文档,我可以写入 docs/支付案例-统一支付详解.md;或者按「评审 PPT 大纲」「接口对接文档」「新人 onboarding」再拆一版。
更多推荐

所有评论(0)