统一支付方案详解(案例版)

下面只讲当前主流程:requestMiniProgramPay → /bank/pay/pay → 按环境唤起支付 → 回跳 → 订单轮询确认。


一、方案要解决什么问题

护理患者端 H5 跑在两个容器里:

容器典型场景支付能力
微信公众号内浏览器用户从公众号菜单进入只能跳外部收银台 URL
微信小程序 WebView用户从小程序打开 H5可 navigateTo 到小程序原生页调起微信支付

如果各写一套支付逻辑,业务页会重复、环境判断散落、结果确认不一致。当前方案的做法是:

  1. 业务页只调一个函数 requestMiniProgramPay({ orderNo })
  2. 后端只提供一个接口 POST /bank/pay/pay,用 source 区分返回 payUrl 还是微信支付参数
  3. 小程序场景不直接在 H5 里调微信支付,而是把参数交给宿主小程序的 thirdPay 页面
  4. 支付平台前端回调不作为最终成功依据,以业务订单 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,支付成功后清草稿

小程序侧(本仓库外)需实现:

  1. 读 action=thirdPay
  2. 解析 payInfo,调 wx.requestPayment
  3. 支付结束按约定拼 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写入时机用途
localStorageminiProgramPayOrderNo发起支付前回跳时兜底订单号
localStorageminiProgramPayResponseAPI 返回后调试/排查最近一次响应
localStorageminiProgramPayCleanupOrderNo传了 onSuccess 时标记支付成功后需清草稿
sessionStorageminiProgramPayConfirming前端 success 时防重复支付,10 分钟 TTL
sessionStorageorder_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未获取到订单号否
防重正在请求/跳转静默忽略等上次结束
APIcode 非 200alertMsg 或「获取支付参数失败」是
API网络/拦截器 400Toast(alertMsg)是
公众号无 payUrl未获取到支付地址是
小程序五参数缺一微信支付参数不完整是
小程序无 wx.miniProgram当前环境不支持小程序支付否
小程序navigateTo 失败支付调用失败是
结果payStatus=failpayDesc 或「支付失败」是
结果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」再拆一版。

更多推荐