做源码商城、转卡码发卡这类业务的朋友都知道:用户从搜索引擎、朋友圈广告或者短信落地页进来,第一屏往往是手机浏览器里的 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 固定填 Wap;app_name 是商户在 H5 收银台展示的应用名称;app_url 必须是经过微信支付商户平台配置的合法域名,否则下单会直接报 APPID_MCHID_NOT_MATCH 或 H5_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 快速二次开发支付页面。需要现成源码的朋友,欢迎逛逛源码商城。