
线下扫码支付这个场景说直白点就是用户拿手机扫一个静态二维码直接拉起微信支付完成付款。听起来简单但真到落地的时候坑比想象中多得多。我做小程序支付这块前前后后踩了不少雷尤其是用weixin://wxpay/bizpayurl这个协议做线下扫码的时候文档里没写清楚的地方太多了。这篇内容就把整个链路拆开讲透从协议原理、参数生成、签名计算到实际调试中遇到的各种奇葩问题全部摊开说。适合正在做线下收款、门店点单、自助设备这类场景的开发者也适合想搞清楚微信支付底层逻辑的朋友。看完你至少能少走两三个月的弯路。1. 线下扫码支付的整体链路设计1.1 为什么选 weixin://wxpay/bizpayurl 而不是普通二维码先搞清楚一个前提线下扫码支付有两种常见做法。一种是把支付链接生成二维码用户用微信扫一扫打开一个 H5 页面再在页面里调起支付。另一种就是本文要讲的直接生成weixin://wxpay/bizpayurl?prxxxxx这种格式的二维码微信扫一扫识别后直接拉起支付确认界面。这两种方式的体验差距非常大。第一种方式用户扫码后要等页面加载网络差的时候转圈半天而且页面里还得再点一次“立即支付”多一步操作就多一层流失。第二种方式微信客户端直接识别协议跳过网页加载环节扫码即弹支付窗口整个流程缩短到两秒以内。我实测过同样一个门店点餐场景用 H5 中转的方案支付转化率大概在 70% 左右换成bizpayurl协议直接拉起转化率能到 88% 以上。这个差距在日订单量上千的场景里就是实打实的营收差异。那这个协议到底是什么weixin://wxpay/bizpayurl是微信客户端内置的一个 URL Scheme专门用于处理支付相关的跳转。后面的pr参数是一个经过微信支付系统生成的短链接标识微信扫一扫识别到这个协议后会拿pr值去微信支付后台换取真正的支付参数然后直接弹出支付确认页。注意这个协议只能在微信客户端内部被识别用系统相机或者其他扫码工具扫出来是一串无法打开的文本。所以二维码的投放场景必须是用户用微信扫一扫来扫。1.2 完整支付链路的六个环节整个线下扫码支付的链路我把它拆成六个环节每个环节都有对应的技术实现和注意事项商户系统下单后端调用微信支付统一下单接口现在叫 JSAPI/ Native 下单传入商户订单号、金额、回调地址等参数。获取 code_url微信支付返回一个code_url格式就是weixin://wxpay/bizpayurl?prxxxxx。生成二维码把这个code_url用二维码生成库转成图片打印或展示在收银台。用户扫码用户用微信扫一扫识别二维码微信客户端解析协议。微信拉起支付微信拿pr参数去后台换取支付信息弹出支付确认界面。支付结果通知用户完成支付后微信服务器异步通知商户后端同时前端可以轮询查单。这六个环节里最容易出问题的是第 2 步和第 6 步。第 2 步的code_url有有效期限制默认两小时过期后扫码会提示“二维码已过期”。第 6 步的异步通知如果商户后端处理不当会出现“用户已付款但订单显示未支付”的情况。1.3 模式选择Native 支付 vs JSAPI 支付这里要澄清一个容易混淆的点。weixin://wxpay/bizpayurl这个协议对应的是微信支付的Native 支付模式不是 JSAPI 支付。JSAPI 支付是在微信内置浏览器里通过WeixinJSBridge调起支付需要传appId、timeStamp、nonceStr、package、signType、paySign这一整套参数。Native 支付则简单得多后端调统一下单接口时传trade_typeNATIVE微信返回一个code_url这个code_url就是weixin://wxpay/bizpayurl?prxxxxx格式。你只需要把它转成二维码就行不需要前端参与任何签名计算。我见过不少开发者把这两种模式搞混在 Native 支付里去找paySign找了半天找不到因为根本不需要。Native 支付的签名全部在后端完成前端只负责展示二维码。对比项Native 支付JSAPI 支付适用场景线下扫码、PC 网站微信内置浏览器调起方式扫码识别协议WeixinJSBridge前端签名不需要需要 paySign返回参数code_urlprepay_id用户标识不需要 openid需要 openid2. 后端下单与 code_url 生成的核心细节2.1 统一下单接口的参数拆解Native 支付调的是微信支付的统一下单接口现在 V3 版本的接口地址是https://api.mch.weixin.qq.com/v3/pay/transactions/native。请求体是 JSON 格式核心参数如下{ appid: wx1234567890abcdef, mchid: 1900000109, description: 门店点餐-订单号20240115001, out_trade_no: ORDER20240115001, time_expire: 2024-01-15T12:00:0008:00, notify_url: https://yourdomain.com/pay/notify, amount: { total: 100, currency: CNY }, scene_info: { store_info: { id: STORE001, name: 示例门店, area_code: 440305, address: 广东省深圳市南山区示例路1号 } } }几个关键参数需要重点说明out_trade_no是商户订单号必须保证同一商户号下唯一。我一般用“业务前缀日期流水号”的格式比如ORDER20240115001。这个号重复了微信会直接报错所以生成逻辑要加锁或者用数据库唯一索引兜底。time_expire是订单过期时间格式是 RFC3339。不传的话默认两小时。线下场景我建议设短一点比如 5 分钟因为用户扫码后一般很快就会支付设太长反而容易造成订单堆积。amount.total是金额单位是分。这个坑我踩过一开始传了 1.00 以为是一块钱结果实际扣了一分钱。微信支付所有金额字段都是整数分没有小数。notify_url是支付结果异步通知地址必须是 HTTPS不能带参数不能是内网地址。这个地址收到通知后必须返回 200 或者微信认可的响应格式否则微信会按策略重试。2.2 签名计算V3 版本的 RSA 签名V3 接口的签名和 V2 完全不一样。V2 用的是 MD5 或 HMAC-SHA256V3 用的是 SHA256withRSA。签名串的构造规则是HTTP方法\nURL路径\n时间戳\n随机串\n请求体\n注意每一行末尾都有\n包括最后一行。请求体如果是 GET 请求就是空字符串。时间戳是秒级 Unix 时间戳随机串是任意字符串一般用 UUID 去掉横线。签名用的私钥是商户 API 证书里的apiclient_key.pem。签名流程是用私钥对签名串做 SHA256withRSA 签名然后 Base64 编码放到请求头的Authorization字段里。import time import uuid import base64 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding def build_signature(method, url_path, body, private_key_path, mchid, serial_no): timestamp str(int(time.time())) nonce_str uuid.uuid4().hex message f{method}\n{url_path}\n{timestamp}\n{nonce_str}\n{body}\n with open(private_key_path, rb) as f: private_key serialization.load_pem_private_key(f.read(), passwordNone) signature private_key.sign( message.encode(utf-8), padding.PKCS1v15(), hashes.SHA256() ) sign_base64 base64.b64encode(signature).decode(utf-8) auth_header ( fWECHATPAY2-SHA256-RSA2048 mchid{mchid}, fnonce_str{nonce_str}, fsignature{sign_base64}, ftimestamp{timestamp}, fserial_no{serial_no} ) return auth_header这段代码里serial_no是商户 API 证书的序列号不是证书内容本身。获取方式是在微信支付商户平台下载证书后用openssl x509 -in apiclient_cert.pem -noout -serial命令查看。注意私钥文件绝对不能泄露不要提交到代码仓库不要放在前端能访问的目录。我一般放在服务器的/etc/wechatpay/目录下权限设成 600只有运行服务的用户能读。2.3 code_url 的返回与有效期管理下单成功后微信返回的响应体里有一个code_url字段格式就是weixin://wxpay/bizpayurl?prxxxxx。这个pr值是一个短标识长度大概 20 多个字符。{ code_url: weixin://wxpay/bizpayurl?prABCDEFGHIJKLMNOP }这个code_url的有效期和订单的time_expire一致。如果没传time_expire默认两小时。过期后用户扫码微信会提示“二维码已过期请重新获取”。实际运营中我建议把code_url和订单号一起存到数据库并且记录生成时间。如果用户扫码时提示过期前端可以引导用户点击“刷新二维码”后端重新调下单接口生成新的code_url。但要注意重新下单时out_trade_no不能重复要么用新的订单号要么先调关单接口把旧订单关掉。这里有个细节同一个out_trade_no如果已经下单成功但未支付再次用同样的参数调下单接口微信会返回同样的code_url不会报错。但如果参数有变化比如金额变了就会报“订单号重复”的错误。所以刷新二维码时如果金额没变可以直接复用原来的code_url不用重新下单。3. 二维码生成与线下投放的实操要点3.1 二维码生成库的选择与参数调优拿到code_url后下一步是把它转成二维码图片。常用的库有 Python 的qrcode、Java 的ZXing、Node.js 的qrcode。我用得最多的是 Python 的qrcode简单直接。import qrcode def generate_qrcode(code_url, output_path): qr qrcode.QRCode( versionNone, error_correctionqrcode.constants.ERROR_CORRECT_M, box_size10, border4, ) qr.add_data(code_url) qr.make(fitTrue) img qr.make_image(fill_colorblack, back_colorwhite) img.save(output_path)几个参数需要根据实际场景调整error_correction是容错级别有 L、M、Q、H 四档分别对应 7%、15%、25%、30% 的容错率。线下场景二维码可能被磨损、遮挡我一般用 M 或 Q。如果二维码要印在户外易损材质上直接用 H。box_size是每个小方块的像素数默认 10。如果二维码要打印得很大比如贴在墙上box_size 可以设大一点比如 20这样生成的图片分辨率更高打印出来更清晰。border是二维码四周的留白单位是小方块数默认 4。这个留白不能省微信扫一扫识别时需要这个白边来定位。我见过有人为了省空间把 border 设成 1结果扫码识别率明显下降。提示生成的二维码图片建议保存为 PNG 格式不要用 JPG。JPG 是有损压缩二维码边缘容易出现噪点影响识别率。3.2 二维码的线下投放与物料设计二维码生成出来只是第一步怎么投放到线下场景才是决定成败的关键。我做过门店收银台、餐桌贴纸、自助售货机三种场景每种场景的注意事项都不一样。收银台场景二维码一般放在收银员旁边用户结账时扫。这种场景二维码尺寸不用太大5cm x 5cm 就够了但位置要显眼最好配一个“扫码支付”的指示牌。我见过有的门店把二维码贴在收银台侧面用户根本看不到转化率自然低。餐桌场景二维码贴在桌角或桌面上用户点餐时扫。这种场景二维码要耐磨因为会被杯子、盘子压到。我建议用亚克力立牌或者防水贴纸不要用普通打印纸。另外桌贴二维码一般要绑定桌号所以每个桌子的二维码内容不一样out_trade_no里要带上桌号信息。自助设备场景二维码显示在屏幕上用户扫码支付后设备出货。这种场景二维码是动态生成的每次交易都要刷新。要注意屏幕亮度和对比度太暗或反光都会影响识别。我实测下来屏幕亮度调到 80% 以上二维码用黑底白字反色识别率更高。场景二维码尺寸材质建议特殊要求收银台5cm x 5cm亚克力立牌配指示牌餐桌4cm x 4cm防水贴纸绑定桌号自助设备屏幕显示动态生成高亮度反色3.3 二维码内容的安全防护code_url本身不包含金额和商户信息只是一个短标识所以即使被人拍照转发也只能用于支付对应的订单不能篡改金额。这一点比 H5 链接安全因为 H5 链接里的参数可能被篡改。但有一个风险要注意如果二维码对应的订单金额较大被人恶意拍照后抢先支付虽然钱是付给商户的但可能造成订单归属纠纷。防范办法是设置较短的time_expire比如 5 分钟并且支付成功后立即关单。另外code_url不要在前端日志里打印也不要在接口返回时暴露给无关人员。虽然后端下单接口本身需要鉴权但code_url一旦泄露在有效期内任何人都能扫码支付。我一般只在服务端日志里记录订单号和金额不记录完整的code_url。4. 支付结果通知与订单状态同步4.1 异步通知的接收与验签用户支付成功后微信服务器会向notify_url发送一个 POST 请求请求体是加密的 JSON。V3 版本的通知体格式如下{ id: EV-20240115001, create_time: 2024-01-15T10:30:0008:00, resource_type: encrypt-resource, resource: { algorithm: AEAD_AES_256_GCM, ciphertext: xxxxx, associated_data: transaction, nonce: xxxxx } }resource里的ciphertext是加密的支付结果需要用 APIv3 密钥解密。解密算法是 AEAD_AES_256_GCM密钥就是你在商户平台设置的 APIv3 密钥32 位字符串。from cryptography.hazmat.primitives.ciphers.aead import AESGCM import base64 def decrypt_notification(apiv3_key, nonce, ciphertext, associated_data): key apiv3_key.encode(utf-8) nonce_bytes nonce.encode(utf-8) ciphertext_bytes base64.b64decode(ciphertext) associated_data_bytes associated_data.encode(utf-8) aesgcm AESGCM(key) plaintext aesgcm.decrypt(nonce_bytes, ciphertext_bytes, associated_data_bytes) return plaintext.decode(utf-8)解密后得到的是支付结果的 JSON里面包含out_trade_no、transaction_id、trade_state、amount等字段。trade_state为SUCCESS表示支付成功。验签方面V3 通知的签名在请求头的Wechatpay-Signature字段里需要用微信支付平台证书的公钥来验证。平台证书可以通过https://api.mch.weixin.qq.com/v3/certificates接口获取但要注意这个接口本身也需要签名。注意异步通知可能会重复发送所以订单状态更新必须做幂等处理。我一般用out_trade_no作为唯一键更新时加WHERE status UNPAID条件避免重复更新。4.2 主动查单作为兜底方案异步通知虽然可靠但网络抖动、服务器重启等情况可能导致通知丢失。所以必须有一个主动查单的兜底机制。查单接口是https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid{mchid}GET 请求同样需要 V3 签名。我一般在前端轮询和后端定时任务两个层面做查单前端轮询用户扫码支付后前端每 2 秒调一次商户后端的查单接口连续查 30 秒。如果查到支付成功跳转成功页如果 30 秒还没查到提示用户“支付结果确认中请稍后查看订单”。后端定时任务对于超过 5 分钟还是UNPAID状态的订单后端定时任务每 5 分钟调一次微信查单接口查到SUCCESS就更新订单状态查到CLOSED或REVOKED就标记为已关闭。def query_order(out_trade_no, mchid): url_path f/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid{mchid} auth_header build_signature(GET, url_path, , private_key_path, mchid, serial_no) headers { Authorization: auth_header, Accept: application/json, User-Agent: your-app/1.0 } response requests.get(fhttps://api.mch.weixin.qq.com{url_path}, headersheaders) return response.json()这个兜底机制我强烈建议加上。我遇到过好几次异步通知延迟十几分钟才到的情况如果没有主动查单用户早就投诉了。4.3 订单状态机设计订单状态管理看起来简单但实际做起来容易乱。我建议用一个明确的状态机来管理状态含义可转换到CREATED已下单待支付PAID, CLOSED, EXPIREDPAID支付成功REFUNDING, REFUNDEDCLOSED已关闭无EXPIRED已过期无REFUNDING退款中REFUNDED, PAIDREFUNDED已退款无状态转换必须通过数据库事务保证原子性。比如从CREATED转到PAID时SQL 要写成UPDATE orders SET statusPAID WHERE out_trade_no? AND statusCREATED根据影响行数判断是否更新成功。如果影响行数为 0说明订单已经被其他线程更新过了直接忽略。这个设计的好处是无论异步通知和主动查单谁先到都只有一次能成功更新状态不会出现重复加钱或者状态错乱的问题。5. 常见问题排查与避坑经验5.1 扫码后提示“商户参数格式错误”这个问题我遇到的最多原因通常有三个第一个是appid和mchid不匹配。微信支付要求appid必须是和商户号绑定的那个如果用了其他小程序的appid就会报这个错。检查方法是登录商户平台在“产品中心-APPID 账号管理”里确认绑定的appid列表。第二个是签名计算错误。V3 签名的签名串构造非常严格每一行末尾的\n都不能少包括最后一行。我见过有人用\r\n导致签名失败也见过有人把请求体做了 JSON 格式化加了空格和换行导致签名串和实际请求体不一致。请求体必须和签名时用的字符串完全一致不能有任何差异。第三个是证书序列号传错。serial_no是商户 API 证书的序列号不是平台证书的序列号。这两个容易搞混。商户 API 证书是你自己下载的平台证书是微信支付平台的。下单请求头里用的是商户 API 证书的序列号。5.2 支付成功但订单状态未更新这个问题的排查思路是分层的先看微信支付后台的订单状态。登录商户平台在“交易中心”查这个订单号如果显示“支付成功”说明钱确实到了问题出在通知接收环节。然后检查notify_url是否可访问。用curl或者 Postman 模拟微信的通知请求看你的接口能不能正常返回 200。注意微信要求通知接口必须在 5 秒内返回如果处理逻辑太重比如同步调用了其他慢接口可能超时导致微信认为通知失败。再看解密是否成功。APIv3 密钥如果填错解密会直接抛异常。检查商户平台设置的 APIv3 密钥和代码里用的是否一致。这个密钥设置后不能查看只能重置所以如果忘了就只能重置一个新的。最后看幂等逻辑是否有问题。如果订单状态更新的 SQL 条件写错了比如WHERE statusUNPAID但实际状态字段值是CREATED就会更新失败。5.3 二维码识别率低的优化技巧二维码识别率低在线下场景很常见尤其是光线不好或者二维码磨损的情况下。我总结了几条优化经验对比度要足够。二维码的黑色模块和白色背景的对比度越高越好。如果打印在彩色背景上识别率会下降。我一般要求二维码区域必须是纯白底黑码不要加 logo不要加装饰。尺寸不能太小。二维码的物理尺寸建议不小于 3cm x 3cm如果扫码距离超过 30cm尺寸要相应放大。有个经验公式扫码距离cm除以 10就是二维码最小边长cm。比如用户离二维码 50cm那二维码至少 5cm。容错级别适当提高。前面说过M 级别容错 15%Q 级别容错 25%。如果二维码可能被遮挡或磨损直接用 Q 或 H。但容错级别越高二维码模块越多同样尺寸下每个模块越小所以要在容错和识别距离之间平衡。提示测试二维码识别率时不要只用一台手机测。不同品牌、不同型号的手机摄像头素质差异很大建议至少用 5 台不同品牌的手机各测 20 次统计识别成功率。5.4 常见问题速查表问题现象可能原因排查方法解决方案扫码提示参数格式错误appid 与 mchid 不匹配商户平台查绑定关系换用正确的 appid扫码提示签名错误签名串构造错误打印签名串逐行核对确保每行末尾有 \n扫码提示证书错误serial_no 传错对比证书序列号用商户 API 证书序列号支付成功订单未更新notify_url 不可达curl 模拟通知修复接口或网络支付成功订单未更新解密失败检查 APIv3 密钥重置密钥并更新配置二维码扫不出来对比度不足换纯白底黑码重新生成二维码二维码扫不出来尺寸太小测量物理尺寸放大到至少 3cm二维码过期time_expire 到期查订单创建时间重新下单生成新码6. 生产环境部署与监控建议6.1 密钥与证书的安全管理生产环境里商户私钥和 APIv3 密钥是最核心的资产。我的做法是私钥文件放在独立的配置服务器或者密钥管理服务里应用启动时通过内网接口拉取不落盘。如果条件有限只能放本地那至少要做到文件权限 600所属用户是应用运行用户目录不在 Web 根目录下不被版本控制工具追踪。APIv3 密钥同理不要硬编码在代码里用环境变量或者配置中心注入。我见过有人把密钥写在application.yml里然后提交到了公开仓库结果被人扫到后恶意下单损失了好几万。证书序列号可以公开但也要统一管理不要散落在各个代码文件里。我一般建一个wechatpay_config表存mchid、appid、serial_no、notify_url这些配置应用启动时加载到内存。6.2 支付链路的监控指标支付是核心业务必须有完善的监控。我一般监控这几个指标下单成功率调统一下单接口的成功率如果低于 99%说明参数或者网络有问题。这个指标按分钟统计异常时告警。支付成功率用户扫码后实际完成支付的比例。这个指标受二维码识别率、用户操作意愿等多方面影响线下场景一般 80% 以上算正常。如果突然下降可能是二维码物料损坏或者收银员引导有问题。通知到达率异步通知成功接收并处理的比例。这个指标应该接近 100%如果低于 99.9%说明notify_url或者处理逻辑有问题。查单补偿量通过主动查单发现支付成功的订单数量。这个指标如果持续偏高说明异步通知链路不稳定需要排查。对账差异每天和微信支付的对账单做比对发现金额或状态不一致的订单。这个是最兜底的监控能发现前面所有环节都漏掉的问题。6.3 对账与日终处理微信支付提供对账单下载接口每天上午 10 点左右生成前一天的账单。我建议每天定时下载对账单和本地订单表做比对。对账单是 CSV 格式包含交易时间、商户订单号、微信订单号、金额、状态等字段。比对逻辑是遍历对账单里的每一笔交易在本地订单表里查对应的out_trade_no如果本地状态不是PAID就更新为PAID如果本地没有这个订单就记录异常人工排查。反向也要查本地标记为PAID但对账单里没有的订单可能是异步通知伪造或者数据错乱需要重点排查。这个对账流程我一般写成定时任务每天跑一次结果发到运营群或者邮件。发现差异时自动告警人工介入处理。6.4 高并发场景的优化如果线下门店多、订单量大下单接口可能会成为瓶颈。我做过一个日订单 10 万 的系统分享几个优化点下单接口做异步化。用户点击下单后先写一条CREATED状态的订单记录然后异步调微信下单接口拿到code_url后更新订单记录。前端轮询查code_url是否生成。这样接口响应时间从 500ms 降到 50ms。code_url做缓存。同一个订单如果多次请求二维码不需要重复调微信接口直接从缓存里取。缓存 key 用out_trade_no过期时间和订单time_expire一致。数据库连接池调优。支付相关操作都是短事务连接池大小可以适当调大但不要超过数据库最大连接数。我一般设成 CPU 核数的 2 倍再加 10。异步通知处理做队列化。收到微信通知后先写一条通知记录到数据库然后丢到消息队列里异步处理。这样通知接口可以快速返回 200避免超时。最后再分享一个小技巧调试阶段可以用微信支付的沙箱环境但沙箱环境的code_url和正式环境格式一样扫码后不会真的扣钱。不过沙箱环境有诸多限制比如不支持部分接口、对账单格式不同所以正式上线前一定要在正式环境用小额订单比如 1 分钱完整跑一遍全流程。我见过有人沙箱测得好好的一上正式环境就出问题就是因为沙箱和正式的差异没注意到。