做源码商城、转卡码发卡这类业务的朋友都知道:用户从搜索引擎、朋友圈广告或者短信落地页进来,第一屏往往是手机浏览器里的 H5 页面。这时候用户想用微信付款,既不在微信 App 内(用不了 JSAPI),面前又没有电脑(用不了 Native 扫码),怎么办?答案就是微信支付 H5 支付(MWEB 支付):在手机浏览器里直接拉起微信收银台,用户确认后完成付款,全程不离开当前页面流程。

这篇文章把 H5 支付从下单到自动发货的完整链路拆开讲:适用场景选型、V3 统一下单的 h5_info 参数、h5_url 拉起收银台、回调验签与 AES-256-GCM 解密、幂等自动发货与查单兜底,全部附上可直接运行的 Python 代码。

一、H5 支付到底是什么?先搞清楚三种支付方式的边界

微信支付 V3 体系下,网页端最常见的三种支付方式各有各的适用环境,选错了就拉不起收银台:

支付方式适用环境拉起方式是否需要 openid
JSAPI 支付微信内置浏览器(公众号内)wx.chooseWXPay 拉起需要
Native 支付PC 网站、线下扫码生成 code_url 二维码不需要
H5 支付(MWEB)手机系统浏览器(Safari/Chrome 等)302 跳转 h5_url 拉起不需要

判断逻辑一句话:看用户当前在哪个环境里。页面在微信内打开就用 JSAPI,在手机浏览器打开就用 H5,在电脑上就用 Native。成熟的商城通常三套都接,后端根据 User-Agent 自动路由。H5 支付和 JSAPI 很容易被搞混——JSAPI 只能在微信内置浏览器用,而 H5 支付恰恰禁止在微信内置浏览器使用(会被拦截并提示"请在外部浏览器打开"),两者是互补关系。

二、V3 统一下单:h5_info 是灵魂参数

H5 支付走的是 V3 统一下单接口 POST /v3/pay/transactions/h5,和 Native 最大的区别是请求体里多了 scene_info.h5_info 场景信息,而且不需要 openid。一个完整的下单请求体长这样:

import json, time, uuid, base64, requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding

# 1. 构造请求体,金额单位是"分",h5_info 必填
payload = {
    "appid": APPID,
    "mchid": MCHID,
    "description": "转卡码系统 V3 商业授权",
    "out_trade_no": f"QR{int(time.time())}{uuid.uuid4().hex[:6]}",
    "notify_url": "https://greenfield.ltd/store/api/wxpay/notify",
    "amount": {"total": 19900, "currency": "CNY"},
    "scene_info": {
        "payer_client_ip": "112.65.123.45",
        "h5_info": {
            "type": "Wap",
            "app_name": "源码商城",
            "app_url": "https://greenfield.ltd/store/"
        }
    }
}

这里有几个容易踩的细节:h5_info.type 固定填 Wapapp_name 是商户在 H5 收银台展示的应用名称;app_url 必须是经过微信支付商户平台配置的合法域名,否则下单会直接报 APPID_MCHID_NOT_MATCHH5_DOMAIN_ERROR。另外 H5 支付的 description 会展示在收银台,建议写清楚商品名,减少用户疑心、降低投诉率。

三、签名与发起请求:和 Native 同一套 V3 签名

V3 接口统一用商户私钥对 请求方法\nURL\n时间戳\n随机串\n请求体\n 做 SHA256-RSA 签名,H5 下单没有任何特殊之处,直接复用:

# 2. 生成请求签名
def wx_sign(method, url, body):
    ts = str(int(time.time()))
    nonce = uuid.uuid4().hex
    message = f"{method}\n{url}\n{ts}\n{nonce}\n{body}\n"
    with open("merchant_private_key.pem", "rb") as f:
        key = serialization.load_pem_private_key(f.read(), password=None)
    sig = base64.b64encode(
        key.sign(message.encode(), padding.PKCS1v15())).decode()
    return ts, nonce, sig

# 3. 发起 H5 下单,注意 URL 用完整路径
body = json.dumps(payload, ensure_ascii=False)
ts, nonce, sig = wx_sign("POST", "/v3/pay/transactions/h5", body)
resp = requests.post(
    "https://api.mch.weixin.qq.com/v3/pay/transactions/h5",
    data=body.encode("utf-8"),
    headers={
        "Authorization": (
            'WECHATPAY2-SHA256-RSA2048 mchid="' + MCHID +
            '",nonce_str="' + nonce + '",signature="' + sig +
            '",timestamp="' + ts + '",serial_no="' + SERIAL_NO + '"'
        ),
        "Content-Type": "application/json",
        "Accept": "application/json",
        "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
    },
)
# 4. 返回的 h5_url 就是拉起收银台的钥匙
h5_url = resp.json()["h5_url"]
print(h5_url)
# https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx...&package=...

这里有个隐蔽的坑:V3 签名要求请求头 User-Agent 必须包含客户端信息,虽然官方文档说非必填,但实测不传或传得太"干净"会在部分风控策略下被拒。上面代码里故意模拟了 iPhone 的 UA,生产环境建议从用户请求头里透传真实的 UA。

四、前端拉起收银台:一个 302 就够了

拿到 h5_url 之后,后端只需要把用户 302 重定向过去,微信会自动完成"打开微信 App → 拉起收银台 → 用户确认支付 → 回到浏览器"的闭环。用 FastAPI 写就是三行:

from fastapi import FastAPI
from fastapi.responses import RedirectResponse

app = FastAPI()

@app.get("/pay/h5/{order_no}")
async def h5_pay(order_no: str):
    # 从数据库取出订单,调统一下单拿到 h5_url(见上文)
    h5_url = create_h5_order(order_no)
    # 直接 302,微信自动拉起收银台
    return RedirectResponse(h5_url, status_code=302)

前端可以不做任何额外处理,链接跳过去就行。不过有两个体验细节值得注意:一是 iOS 上跳转微信再返回时,浏览器会重新加载页面,所以前端要用 sessionStorage 记住订单号,页面恢复后主动向后端查单,避免用户看到"已支付但页面还停在待支付";二是安卓部分浏览器对 302 跳转有拦截,可以改成返回 JSON 让前端 window.location.href = h5_url 跳转,兼容性更好。

五、回调通知:验签 + AES-256-GCM 解密

支付成功后,微信会异步向 notify_url 推送通知。V3 的通知体是加密的,需要先用 APIv3 密钥做 AES-256-GCM 解密,再对解密后的明文做验签。这段代码和 JSAPI/Native 完全通用:

# 5. AES-256-GCM 解密回调 resource
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def decrypt_notify(resource: dict) -> dict:
    api_v3_key = open("apiv3.key", "rb").read()  # 32 字节
    aesgcm = AESGCM(api_v3_key)
    plaintext = aesgcm.decrypt(
        resource["nonce"].encode(),
        base64.b64decode(resource["ciphertext"]),
        resource["associated_data"].encode(),
    )
    return json.loads(plaintext)

# 6. 回调入口:解密 + 校验金额 + 幂等发货
@app.post("/store/api/wxpay/notify")
async def wxpay_notify(req: dict):
    data = decrypt_notify(req["resource"])
    if data["trade_state"] != "SUCCESS":
        return {"code": "SUCCESS", "message": "成功"}
    order = get_order(data["out_trade_no"])
    # 金额以"分"为单位比对,防止篡改
    if int(order["amount"]) != int(data["amount"]["total"]):
        return {"code": "FAIL", "message": "金额不一致"}
    deliver_card(order["id"], data["transaction_id"])  # 幂等发货
    return {"code": "SUCCESS", "message": "成功"}

回调一定要做三件事:解密、验金额、幂等发货。微信会重试推送最多 24 小时,任何一步没做好都会导致重复发货或资损。注意返回给微信的报文必须是 {"code":"SUCCESS"},返回其他内容微信会认为失败并继续重试。

六、与转卡码系统的集成:幂等自动发货 + 查单兜底

H5 支付接入转卡码系统后,典型的自动发货流程是:用户手机浏览器下单 → H5 收银台付款 → 回调触发发卡 → 卡密展示在订单页。核心是"回调幂等 + 查单兜底"双保险:

# 7. 幂等发货:数据库状态机保证只发一次
def deliver_card(order_id: str, transaction_id: str):
    # UPDATE ... WHERE status='PAYING' 返回 0 行说明已处理过
    updated = db.execute(
        "UPDATE orders SET status='PAID', transaction_id=%s "
        "WHERE id=%s AND status='PAYING'",
        (transaction_id, order_id),
    )
    if updated.rowcount == 0:
        return  # 已发货或订单不存在,直接幂等返回
    card = db.fetchone(
        "SELECT id, code FROM cards WHERE order_id=%s "
        "AND status='SOLD' LIMIT 1", (order_id,))
    notify_user(order_id, card["code"])  # 短信/站内信推送卡密

# 8. 查单兜底:前端轮询 / 定时任务主动查单
def query_wxpay(out_trade_no: str) -> dict:
    path = f"/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={MCHID}"
    ts, nonce, sig = wx_sign("GET", path, "")
    resp = requests.get("https://api.mch.weixin.qq.com" + path, headers={
        "Authorization": (
            'WECHATPAY2-SHA256-RSA2048 mchid="' + MCHID +
            '",nonce_str="' + nonce + '",signature="' + sig +
            '",timestamp="' + ts + '",serial_no="' + SERIAL_NO + '"'
        ),
        "Accept": "application/json",
    })
    return resp.json()

回调偶尔会迟到甚至丢失(比如回调域名被墙、服务器重启),所以一定要有查单兜底:前端支付返回后轮询订单状态,后端发现订单超时未支付就主动调查单接口,查到 SUCCESS 就补触发发货。这套"回调为主、查单兜底"的机制,正是生产级转卡码系统的标准做法。

七、常见坑点速查表

坑点表现解决办法
微信内打开 H5 链接提示"请在外部浏览器打开"检测 UA 含 MicroMessenger 时引导走 JSAPI
app_url 域名未配置下单报 H5_DOMAIN_ERROR商户平台 → 产品中心 → H5 支付配置支付域名
h5_info 缺失下单报参数错误type 固定 Wap,app_name/app_url 必填
iOS 返回页面重载显示仍停留在待支付sessionStorage 记订单号,回页自动查单
回调解密失败收到通知但解析报错确认用的是 APIv3 密钥而非商户私钥
重复推送重复发卡、资损状态机条件更新 + 订单号唯一约束
回调丢失用户付了款没收到卡查单兜底 + 定时任务补偿

八、小结

H5 支付(MWEB)补上了"手机浏览器场景"这块拼图:V3 统一下单带 h5_info,拿到 h5_url 后 302 拉起收银台,回调解密验签后幂等发货,再用查单兜底防掉单。它和 JSAPI、Native 一起构成了完整的微信支付矩阵,无论用户从哪个入口进来都能顺畅付款。

如果不想从零手写这套支付链路,源码商城在售的转卡码系统 V3 已内置 JSAPI、Native、H5、当面付四种通道的统一封装,以及幂等自动发货、查单补偿、分润结算与对账模块,部署即可用;搭配Codex Desktop 还能用 AI 快速二次开发支付页面。需要现成源码的朋友,欢迎逛逛源码商城