在源码交易场景中,用户可能通过微信小程序浏览商品,也可能通过浏览器直接访问 H5 商城。如果为每个端单独维护一套支付系统,不仅开发成本翻倍,后续的订单对账、退款处理也会变成噩梦。一套优雅的混合支付方案,能让小程序和 H5 使用同一个后端接口、同一套订单数据,仅通过前端支付方式做差异化适配。

本文将以微信支付为例,完整讲解小程序 + H5 混合支付方案的设计思路与代码实现,涵盖统一下单、前端拉起支付、订单查询与回调通知全流程。

一、整体架构设计

核心思路很简单:后端只做一件事——生成预支付订单并返回支付参数。至于前端是用小程序 API 拉起支付,还是用 H5 跳转收银台,完全由前端根据自身环境决定。

架构分为三层:

  • 前端层:小程序端调用 wx.requestPayment,H5 端使用 WeixinJSBridge.invoke('getBrandWCPayRequest') 或 JSAPI 跳转
  • 后端统一接口:一个 /api/pay/unified 接口,接收订单信息 + 支付渠道 + 客户端类型,返回不同格式的支付参数
  • 支付网关层:封装微信支付 API,处理签名、请求、回调
# 请求示例
POST /api/pay/unified
{
  "order_id": "ORD20260712001",
  "amount": 99.00,
  "subject": "转卡码系统企业版",
  "channel": "wechat",          # 支付渠道: wechat / alipay
  "client_type": "mini_program" # 客户端: mini_program / h5 / app
}

# 小程序端返回
{
  "code": 0,
  "data": {
    "nonceStr": "xxx",
    "package": "prepay_id=wx...",
    "paySign": "xxx",
    "signType": "MD5",
    "timeStamp": "1720761600"
  }
}

# H5 端返回
{
  "code": 0,
  "data": {
    "h5_url": "https://wx.tenpay.com/cgi-bin/mmpayweb/xxx"
  }
}

二、后端统一支付接口实现

我们使用 Python(Flask)实现统一支付接口。首先定义一个支付网关类,封装微信支付的统一下单逻辑:

import hashlib
import xml.etree.ElementTree as ET
import requests
import random
import string
from datetime import datetime

# 微信支付配置
WECHAT_CONFIG = {
    "appid": "wx_your_appid",
    "mch_id": "your_mch_id",
    "api_key": "your_api_key_32bytes",
    "notify_url": "https://your-domain.com/api/pay/notify"
}

class WechatPayGateway:
    @staticmethod
    def _gen_nonce_str(length=16):
        return ''.join(random.choices(
            string.ascii_letters + string.digits, k=length
        ))

    @staticmethod
    def _gen_sign(params, api_key):
        # 1. 按 key 字典序排序
        sorted_keys = sorted(params.keys())
        raw = '&'.join(
            f'{k}={params[k]}' for k in sorted_keys
            if params[k] != '' and k != 'sign'
        )
        raw += f'&key={api_key}'
        # 2. MD5 签名
        return hashlib.md5(raw.encode('utf-8')).hexdigest().upper()

    @staticmethod
    def _to_xml(params):
        root = ET.Element('xml')
        for k, v in params.items():
            child = ET.SubElement(root, k)
            child.text = str(v)
        return ET.tostring(root, encoding='utf-8')

    @staticmethod
    def _parse_xml(xml_str):
        root = ET.fromstring(xml_str)
        return {child.tag: child.text for child in root}

    @classmethod
    def unified_order(cls, out_trade_no, total_fee, body,
                      client_type='mini_program'):
        """
        统一下单
        :param client_type: mini_program | h5 | app
        """
        params = {
            'appid': WECHAT_CONFIG['appid'],
            'mch_id': WECHAT_CONFIG['mch_id'],
            'nonce_str': cls._gen_nonce_str(),
            'body': body,
            'out_trade_no': out_trade_no,
            'total_fee': int(total_fee * 100),  # 单位: 分
            'spbill_create_ip': '127.0.0.1',
            'notify_url': WECHAT_CONFIG['notify_url'],
            'trade_type': 'JSAPI' if client_type in (
                'mini_program', 'h5'
            ) else 'APP',
        }

        # H5 支付额外参数
        if client_type == 'h5':
            params['trade_type'] = 'MWEB'
            params['scene_info'] = (
                '{"h5_info": {"type":"Wap","wap_url":'
                '"https://greenfield.ltd/store/","wap_name":"源码商城"}}'
            )

        # 小程序支付需要传 openid
        if client_type == 'mini_program':
            params['openid'] = cls._get_current_openid()

        params['sign'] = cls._gen_sign(params, WECHAT_CONFIG['api_key'])
        xml_data = cls._to_xml(params)

        url = 'https://api.mch.weixin.qq.com/pay/unifiedorder'
        resp = requests.post(url, data=xml_data, timeout=10)
        result = cls._parse_xml(resp.content)

        if result.get('return_code') == 'SUCCESS' \
                and result.get('result_code') == 'SUCCESS':
            prepay_id = result['prepay_id']
            return cls._build_frontend_params(
                prepay_id, client_type
            )

        raise Exception(f"下单失败: {result.get('return_msg', '未知错误')}")

    @classmethod
    def _build_frontend_params(cls, prepay_id, client_type):
        # 小程序: 返回 wx.requestPayment 所需参数
        if client_type == 'mini_program':
            pkg = f'prepay_id={prepay_id}'
            params = {
                'appId': WECHAT_CONFIG['appid'],
                'nonceStr': cls._gen_nonce_str(),
                'package': pkg,
                'signType': 'MD5',
                'timeStamp': str(int(datetime.now().timestamp())),
            }
            params['paySign'] = cls._gen_sign(
                params, WECHAT_CONFIG['api_key']
            )
            return {
                'nonceStr': params['nonceStr'],
                'package': pkg,
                'paySign': params['paySign'],
                'signType': 'MD5',
                'timeStamp': params['timeStamp'],
            }

        # H5: 返回跳转链接
        if client_type == 'h5':
            return {
                'h5_url': (
                    f'https://wx.tenpay.com/cgi-bin/'
                    f'mmpayweb-bin/checkmweb?prepay_id='
                    f'{prepay_id}&package=xxx'
                )
            }

        return {'prepay_id': prepay_id}

三、小程序端拉起支付

在小程序端,调用后端接口获取支付参数后,使用 wx.requestPayment 拉起支付面板:

// 小程序端支付代码
function doPay(orderId) {
  wx.request({
    url: 'https://your-domain.com/api/pay/unified',
    method: 'POST',
    data: {
      order_id: orderId,
      amount: 99.00,
      subject: '转卡码系统企业版',
      channel: 'wechat',
      client_type: 'mini_program'
    },
    success(res) {
      if (res.data.code !== 0) {
        wx.showToast({ title: '下单失败', icon: 'error' });
        return;
      }

      const payParams = res.data.data;

      wx.requestPayment({
        timeStamp: payParams.timeStamp,
        nonceStr: payParams.nonceStr,
        package: payParams.package,
        signType: payParams.signType,
        paySign: payParams.paySign,
        success() {
          // 支付成功,跳转到订单详情
          wx.navigateTo({
            url: `/pages/order/detail?orderId=${orderId}`
          });
        },
        fail(err) {
          wx.showToast({ title: '支付取消', icon: 'none' });
        }
      });
    }
  });
}

四、H5 端拉起支付

在 H5 端,如果是微信内浏览器,使用 WeixinJSBridge;如果是普通浏览器,使用 MWEB 跳转:

// H5 端支付
async function h5Pay(orderId) {
  const resp = await fetch(
    'https://your-domain.com/api/pay/unified',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        order_id: orderId,
        amount: 99.00,
        subject: '转卡码系统企业版',
        channel: 'wechat',
        client_type: 'h5'
      })
    }
  );
  const data = (await resp.json()).data;

  // 判断是否在微信内置浏览器
  const isWechat = navigator.userAgent.toLowerCase()
    .includes('micromessenger');

  if (isWechat) {
    // 微信浏览器中使用 WeixinJSBridge
    if (typeof WeixinJSBridge === 'undefined') {
      document.addEventListener('WeixinJSBridgeReady', () => {
        doWechatPay(data);
      });
    } else {
      doWechatPay(data);
    }
  } else {
    // 普通浏览器直接跳转 MWEB
    window.location.href = data.h5_url;
  }
}

function doWechatPay(params) {
  WeixinJSBridge.invoke(
    'getBrandWCPayRequest',
    {
      appId: params.appId,
      timeStamp: params.timeStamp,
      nonceStr: params.nonceStr,
      package: params.package,
      signType: params.signType,
      paySign: params.paySign
    },
    function(res) {
      if (res.err_msg === 'get_brand_wcpay_request:ok') {
        window.location.href = '/store/order/success.html';
      } else {
        alert('支付取消或失败');
      }
    }
  );
}

五、回调通知与订单状态同步

支付完成后,微信会异步通知你的 notify_url。后端需要验证签名并更新订单状态:

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/api/pay/notify', methods=['POST'])
def pay_notify():
    xml_data = request.data
    result = WechatPayGateway._parse_xml(xml_data)

    # 验证签名
    sign = result.pop('sign', '')
    expected = WechatPayGateway._gen_sign(
        result, WECHAT_CONFIG['api_key']
    )

    if sign != expected:
        return WechatPayGateway._to_xml({
            'return_code': 'FAIL',
            'return_msg': '签名验证失败'
        })

    if result.get('return_code') == 'SUCCESS' \
            and result.get('result_code') == 'SUCCESS':
        out_trade_no = result['out_trade_no']
        transaction_id = result['transaction_id']
        total_fee = int(result['total_fee']) / 100.0

        # 更新订单状态为已支付
        update_order_status(out_trade_no, 'paid', transaction_id)

        # 如果是数字商品,触发自动发货
        auto_deliver(out_trade_no)

        # 返回成功应答
        return WechatPayGateway._to_xml({
            'return_code': 'SUCCESS',
            'return_msg': 'OK'
        })

    return WechatPayGateway._to_xml({
        'return_code': 'FAIL',
        'return_msg': '支付失败'
    })

这里有一个容易被忽略的细节:回调通知有可能重复到达(微信最多重试 3 次)。因此 update_order_status 函数必须是幂等的:

def update_order_status(order_id, status, transaction_id):
    # 使用乐观锁,避免重复更新
    sql = """
    UPDATE orders
    SET status = %s,
        transaction_id = %s,
        pay_time = NOW()
    WHERE order_id = %s
      AND status = 'pending'  -- 只更新待支付的订单
    """
    # 执行 SQL...

六、订单查询与退款

实际运营中还需要提供订单查询和退款接口。后端同样只需暴露一个统一接口,前端传 client_type 区分来源:

@app.route('/api/pay/query', methods=['GET'])
def query_order():
    order_id = request.args.get('order_id')
    # 查询本地订单状态
    order = get_order_by_id(order_id)
    return jsonify({
        'code': 0,
        'data': {
            'order_id': order.order_id,
            'status': order.status,
            'amount': order.amount,
            'pay_time': order.pay_time
        }
    })

@app.route('/api/pay/refund', methods=['POST'])
def refund_order():
    data = request.json
    order_id = data['order_id']
    refund_amount = data.get('amount', 0)

    # 调用微信退款 API
    order = get_order_by_id(order_id)
    refund_params = {
        'appid': WECHAT_CONFIG['appid'],
        'mch_id': WECHAT_CONFIG['mch_id'],
        'nonce_str': WechatPayGateway._gen_nonce_str(),
        'out_trade_no': order_id,
        'out_refund_no': f'RF{order_id}',
        'total_fee': int(order.amount * 100),
        'refund_fee': int(refund_amount * 100) if refund_amount
                       else int(order.amount * 100),
    }
    refund_params['sign'] = WechatPayGateway._gen_sign(
        refund_params, WECHAT_CONFIG['api_key']
    )
    # ... 调用微信退款接口
    return jsonify({'code': 0, 'msg': '退款申请已提交'})

七、部署注意事项

混合支付方案在实际部署时,有几个关键点需要特别注意:

  • 域名一致性:微信支付 JSAPI 和小程序支付都需要在微信商户平台配置授权域名/IP,H5 支付还需要配置 jsapi_ticket 刷新机制
  • OpenID 获取:小程序端通过 wx.login 获取 code 换取 openid;H5 端在微信浏览器中通过 OAuth2 静默授权获取
  • 环境隔离:建议在测试环境和生产环境使用不同的商户号,避免测试数据污染
  • 日志记录:每个支付请求都要记录完整的请求参数和微信返回结果,方便排查问题
# Nginx 配置反向代理到支付服务
server {
    listen 443 ssl;
    server_name your-domain.com;

    location /api/pay/ {
        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_read_timeout 60s;
    }
}

八、从理论到实战

以上代码展示了小程序 + H5 混合支付方案的核心骨架。你完全可以基于这套架构扩展出更多功能:支付宝小程序支付、App 内支付、多商户分账等。如果你不想从零搭建,源码商城的转卡码系统 V3 已经内置了完整的小程序 + H5 混合支付模块,开箱即用,支持微信和支付宝双通道。

对于需要 AI 编程辅助开发的同学,Codex Desktop Linux 版 可以在本地帮你编写和调试支付代码,让开发效率翻倍。