一、为什么单独把 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,核心参数就五个:appidmchiddescription(商品描述)、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 帮你读微信支付官方文档、生成签名代码,开发效率直接翻倍。

浏览源码商城 →