一、为什么单独把 Native 扫码支付拎出来讲
微信支付 V3 接口里有三兄弟:JSAPI(公众号/小程序内支付)、H5(浏览器外跳支付)、Native(PC/线下扫码支付)。前几篇文章聊过微信支付的总体接入,但 Native 扫码支付才是所有自动发卡(转卡码)平台的主战场:用户用微信扫一个二维码,付款后系统自动发货,全程无人值守。Native 模式下用户不离开扫码场景,转化率天然比 H5 跳转高一截,也是源码商城这类自动发货站点最常用的收银方式。
Native 接入本身不复杂,真正的深水区在回调通知:微信 V3 的回调报文是 AES-256-GCM 加密的,解不开就永远收不到"已支付";验签姿势选错,轻则收不到通知,重则被伪造回调白嫖卡密。本文把这两块掰开揉碎,附完整 Python 代码,带你跑通"下单 → 扫码 → 回调解密验签 → 幂等发卡"全链路。
二、准备工作:三把钥匙一个都不能少
对接 V3 需要三把钥匙,缺一把都转不动:
- APIv3 密钥(Key):32 字节随机串,在商户平台 → 账户中心 → API 安全里设置,用于回调报文解密。
- 商户私钥(merchant_private_key.pem):申请商户证书时配套生成,用于请求签名。
- 微信支付平台证书 / 微信支付公钥:用于验证回调签名。新商户现在可以直接用"微信支付公钥"模式,不用再手动下载平台证书。
# 生成商户私钥与证书请求(申请商户证书时用)
openssl ecparam -genkey -name prime256v1 -out merchant_private_key.pem
openssl req -new -key merchant_private_key.pem -out csr.pem \
-subj "/CN=你的商户号"
# 下载微信支付公钥(新模式)保存为 wechat_pub.pem
# APIv3 密钥:32 位随机串,放进环境变量,切勿写进代码仓库
export WX_API_V3_KEY='你的32位密钥'
强烈建议把密钥放环境变量或密钥管理服务,别写死在代码里。我们商城所有支付类源码都遵循这条铁律,.gitignore 里永远躺着 .env。
三、统一下单:拿到 code_url 就成功了一半
Native 支付的统一下单接口是 POST /v3/pay/transactions/native,核心参数就五个:appid、mchid、description(商品描述)、out_trade_no(商户订单号)、amount(金额,单位分)。返回的 code_url 就是二维码内容,渲染给用户扫即可:
import json, time, uuid, base64, requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
# 1. 构造请求体,金额单位是"分"
payload = {
"appid": APPID,
"mchid": MCHID,
"description": "Codex Desktop Linux 永久授权",
"out_trade_no": f"WX{int(time.time())}{uuid.uuid4().hex[:6]}",
"notify_url": "https://greenfield.ltd/store/api/wxpay/notify",
"amount": {"total": 9900, "currency": "CNY"},
}
# 2. 生成请求签名:HTTP方法\nURL\n时间戳\n随机串\n请求体\n
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
ts, nonce, sig = wx_sign("POST", "/v3/pay/transactions/native",
json.dumps(payload))
resp = requests.post(
"https://api.mch.weixin.qq.com/v3/pay/transactions/native",
json=payload,
headers={
"Authorization": (
f'WECHATPAY2-SHA256-RSA2048 mchid="{MCHID}",'
f'nonce_str="{nonce}",signature="{sig}",'
f'timestamp="{ts}",serial_no="{SERIAL_NO}"'),
"Content-Type": "application/json",
})
code_url = resp.json()["code_url"] # 把 code_url 画成二维码返回给前端
code_url 的有效期是 2 小时,过期后用户扫码会提示"订单不存在"。稳妥做法是前端拿到二维码后开启轮询,每 3~5 秒查一次订单状态,同时依赖后端回调兜底——两条腿走路,一条都不能瘸。
四、回调通知:AES-256-GCM 解密是最大的一道坎
用户扫码付款后,微信会把加密的回调 POST 到你的 notify_url。报文长这样:
{
"id": "EV-20260816001",
"event_type": "TRANSACTION.SUCCESS",
"resource_type": "encrypt-resource",
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "base64密文...",
"associated_data": "transaction",
"nonce": "随机串"
}
}
解密三要素:APIv3 密钥(32 字节)+ nonce(12 字节)+ associated_data。用 Python 的 cryptography 库实现:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt_callback(resource):
# 微信回调解密:AES-256-GCM,密钥是 APIv3 Key,aad 是 associated_data
key = WX_API_V3_KEY.encode() # 32 字节
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(
resource["nonce"].encode(), # 12 字节 nonce
base64.b64decode(resource["ciphertext"]),
resource["associated_data"].encode()) # aad 通常为 "transaction"
return json.loads(plaintext)
# 解密后得到明文:trade_state、out_trade_no、transaction_id、amount 等
# {"trade_state":"SUCCESS","out_trade_no":"WX1726400000123abc",...}
解密失败时 GCM 会直接抛异常——最常见的两个原因:APIv3 密钥记错,或把 v2 的 API 密钥当成了 APIv3 密钥。这俩不是一回事:前者在"API 安全"里设置,后者在"APIv3 密钥"里设置,千万别混。
五、验签:平台证书模式与微信支付公钥模式
解密之后还要验签。V3 回调的签名响应头长这样:
Wechatpay-Timestamp: 1726400000
Wechatpay-Nonce: 随机串
Wechatpay-Signature: base64签名
Wechatpay-Serial: 平台证书序列号
验签就是把 时间戳\n随机串\n请求体\n 拼起来,用微信支付公钥验 RSA-SHA256:
from cryptography.hazmat.primitives import hashes
def verify_callback(headers, body):
message = (f"{headers['Wechatpay-Timestamp']}\n"
f"{headers['Wechatpay-Nonce']}\n{body}\n")
with open("wechat_pub.pem", "rb") as f: # 微信支付公钥(新模式)
pub = serialization.load_pem_public_key(f.read())
try:
pub.verify(
base64.b64decode(headers["Wechatpay-Signature"]),
message.encode(),
padding.PKCS1v15(),
hashes.SHA256())
return True
except Exception:
return False # 验签失败必须拒绝,并返回非 200
验签失败一定不要返回 200,否则微信会认为通知成功、不再重试,这笔订单就永远停在"已支付未发货"。正确姿势是返回 4xx/5xx,微信会按 15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h 的节奏重试,直到你处理成功。
六、幂等自动发货 + 订单查询兜底
回调处理的核心原则和我们转卡码系统一模一样:验签 → 幂等锁 → 状态机推进 → 原子发卡。微信回调最多重试 11 次,不幂等就会重复发卡:
@app.post("/api/wxpay/notify")
def wxpay_notify():
body = request.get_data(as_text=True)
if not verify_callback(request.headers, body):
return "验签失败", 400
event = decrypt_callback(request.json["resource"])
if event["trade_state"] != "SUCCESS":
return "success" # 非成功状态直接确认,不发货
order_no = event["out_trade_no"]
# 幂等锁:同一订单同时只允许一个回调线程处理
if not r.set(f"wxpay:{order_no}", 1, nx=True, ex=300):
return "success"
# 状态机推进:只有 PENDING 能变成 PAID,条件 UPDATE 天然防重
n = db.execute(
"UPDATE orders SET status='PAID', txn_id=? "
"WHERE order_no=? AND status='PENDING'",
(event["transaction_id"], order_no)).rowcount
if n:
card = db.take_one_card_for(order_no)
deliver(order_no, card) # 展示卡密/推送通知
return "success"
万一回调丢了怎么办?用订单查询接口兜底:GET /v3/pay/transactions/out-trade-no/{out_trade_no}。拉起一个定时任务,把"创建超过 5 分钟仍未支付成功"的订单主动查一遍,查到 SUCCESS 就走同一套发货逻辑。回调 + 前端轮询 + 主动查询三保险,订单就不会"卡死"。
七、curl 联调与高频坑位清单
联调阶段别急着写代码,先用 curl 把接口打通(本地用 ngrok/frp 内网穿透暴露 notify_url):
# 用商户私钥生成签名后,模拟微信回调到本地地址
curl -X POST https://your-ngrok.xxx/api/wxpay/notify \
-H "Content-Type: application/json" \
-H "Wechatpay-Timestamp: 1726400000" \
-H "Wechatpay-Nonce: testnonce" \
-H "Wechatpay-Signature: 你的签名" \
-d '{"resource":{"algorithm":"AEAD_AES_256_GCM","ciphertext":"...","nonce":"...","associated_data":"transaction"}}'
最后把踩过的坑汇总成清单,遇到问题直接对号入座:
- 回调一直收不到:检查
notify_url是否公网可达、是否被 WAF 拦了 POST。 - 解密报错:APIv3 密钥位数不对(必须 32 字节),或把 v2 API 密钥当 v3 用。
- 验签失败:证书序列号不匹配——换了平台证书没换本地公钥,或新旧证书同时有效期内用错了。
- 重复发货:回调重试没做幂等,订单状态没有条件更新。
- 金额校验:发货前必须比对回调金额与订单金额,防止"1 分钱订单"。
八、写在最后
Native 扫码支付是自动发卡平台的命脉,回调解密、验签、幂等这三板斧练熟了,微信通道基本就稳了。如果你想直接拿来用,我们商城的转卡码系统 V3 已内置微信 Native 支付通道,回调解密、验签、幂等发货、超时关单全部开箱即用;再配合Codex Desktop 让 AI 帮你读微信支付官方文档、生成签名代码,开发效率直接翻倍。