简介:这份资源是面向PHP后端开发者与商城项目维护者的微信支付V3完整实例,针对V3接口接入门槛高、签名与证书配置易出错的问题,提供可直接参考的落地代码。压缩包共16个文件,约61KB,以asp与php脚本为主,辅以txt说明、pem证书、mdb数据文件、js脚本及gif图片,覆盖统一下单、前端调起支付、异步回调通知、订单查询确认、退款与异常处理等核心环节,并演示API签名、私钥与公钥证书管理、沙箱环境测试等关键细节。目前已有4630人学习下载,适合希望快速跑通V3支付流程、理解签名与证书机制、并对照排查回调与退款问题的开发者参考,也可作为中小型商城支付模块改造的实践样本。
1. 从一次回调验签失败说起:这套 PHP 微信支付 v3 实例到底能干什么
去年帮一个做知识付费的朋友排查支付问题,用户付完款,后台订单状态死活不变,日志里只有一行Wechatpay-Signature verify failed。他之前用的是网上抄来的 v2 老代码,微信这边早就推 v3 了,证书、签名、解密全换了套玩法。那天我从证书序列号一路查到 AES-256-GCM 解密,才把回调打通。后来我干脆把整套流程整理成一个可复用的 PHP 实例,也就是今天要拆的这份资源。
它解决的不是「怎么调起支付」这种前端小事,而是服务端最容易被卡住的几块:v3 的签名怎么拼、平台证书怎么下载和轮换、回调报文怎么验签和解密、退款和查单怎么发。适合手里有 PHP 项目、需要接微信支付 v3 的后端,尤其是还在用 v2 思维写 v3 代码、被签名和证书反复折腾的人。下面按「先跑通再抠细节」的顺序来,中间会把我踩过的坑一条条摆出来。
2. 环境准备与密钥体系:v3 和 v2 到底差在哪
2.1 为什么 v3 不能照抄 v2 的代码
v2 时代,签名用的是 MD5 或 HMAC-SHA256,密钥就是那串 32 位的 API 密钥,回调是明文 XML,验签基本靠对字段。v3 把整套信任链换成了证书体系:商户有自己的私钥和证书,微信有平台证书,双方用 SHA256-RSA 做签名,回调报文用 AES-256-GCM 加密。这意味着你不能再拿一个字符串当万能钥匙,得管好三样东西——商户私钥、商户证书序列号、平台证书。
很多人第一次接 v3 会懵:我明明按文档拼了签名,为什么还是 401?大概率是签名串的拼接顺序错了。v3 的签名串是五行,每行以\n结尾,顺序固定:HTTP 方法、URL 路径(带 query)、时间戳、随机串、请求体。少一个换行、路径带了域名、GET 请求体写成空字符串而不是空,都会导致验签失败。这个顺序在实例的Signer类里是写死的,照着改参数就行,别自己重排。
2.2 用 OpenSSL 生成商户私钥和证书
微信支付商户平台可以申请 API 证书,但很多时候你需要自己生成一对密钥再上传公钥。常见做法是用 OpenSSL 生成 RSA 2048 私钥,再导出公钥。命令如下:
# 生成 2048 位私钥,PKCS#8 格式,微信要求 openssl genrsa -out apiclient_key.pem 2048 # 从私钥导出公钥 openssl rsa -in apiclient_key.pem -pubout -out apiclient_pub.pem # 查看私钥内容,确认是 BEGIN PRIVATE KEY 而不是 BEGIN RSA PRIVATE KEY head -1 apiclient_key.pem逻辑说明:微信 v3 要求私钥是 PKCS#8 格式,也就是文件头为-----BEGIN PRIVATE KEY-----。如果你用老命令生成的是BEGIN RSA PRIVATE KEY,PHP 的openssl_sign虽然也能读,但上传到商户平台时可能报格式错误。参数上,密钥长度必须 2048 位,1024 位微信不接受。生成后把公钥内容填到商户平台的「API 安全」里,拿到商户证书序列号,这个序列号后面每个请求都要带。
提示:私钥文件不要放进 Web 根目录,实例里默认放在
cert/下并在.gitignore里排除,部署时用环境变量指路径更稳妥。
2.3 平台证书的下载与缓存策略
v3 的回调验签和部分接口响应验签,用的是微信平台证书,不是你的商户证书。平台证书需要通过GET /v3/certificates接口下载,而且这个接口本身也要用商户私钥签名。下载回来的证书是加密的,要用 APIv3 密钥做 AES-256-GCM 解密才能拿到 PEM。
实例里把这一步封装成了CertificateManager,核心逻辑是:先查本地缓存文件,没有或过期(超过 12 小时)就重新下载,解密后按序列号存成多个 PEM 文件。为什么要按序列号存?因为微信平台证书会轮换,新旧证书可能同时在用,验签时要根据回调头里的Wechatpay-Serial找到对应证书。只存一张证书,轮换期间就会验签失败。
// 伪代码示意:下载并解密平台证书 $resp = $client->get('/v3/certificates'); foreach ($resp['data'] as $item) { $plain = $decryptor->aesGcmDecrypt( $item['encrypt_certificate']['ciphertext'], $item['encrypt_certificate']['nonce'], $item['encrypt_certificate']['associated_data'] ); file_put_contents("cert/wechatpay_{$item['serial_no']}.pem", $plain); }参数说明:nonce是 12 字节随机串,associated_data通常是certificate,ciphertext是 Base64 编码的密文。解密时这三者一个都不能错,尤其associated_data传空字符串和传certificate结果完全不同,这是高频翻车点。
3. 下单、签名与回调:把支付主链路跑通
3.1 JSAPI 下单接口的请求构造
主链路从下单开始。以 JSAPI 为例,请求POST /v3/pay/transactions/jsapi,请求体是 JSON。实例里用Client类统一处理签名和发送,你只需要传业务参数。关键参数有appid、mchid、description、out_trade_no、notify_url、amount.total(单位分)、payer.openid。
$client = new WechatPayClient($merchantId, $serialNo, $privateKeyPath, $apiV3Key); $result = $client->post('/v3/pay/transactions/jsapi', [ 'appid' => $appId, 'mchid' => $merchantId, 'description' => '年度会员', 'out_trade_no' => 'ORDER_' . time(), 'notify_url' => 'https://your.domain/notify.php', 'amount' => ['total' => 990, 'currency' => 'CNY'], 'payer' => ['openid' => $openid], ]);逻辑说明:Client内部会做三件事——拼签名串、设置Authorization头、发请求。签名串里的请求体必须是实际发送的 JSON 字符串,不能是数组再序列化一次,否则空格和转义差异会导致签名不一致。参数上,out_trade_no同一商户号下不能重复,重复下单会返回OUT_TRADE_NO_USED。amount.total是整数分,传 9.9 会报参数错误。
下单成功后返回prepay_id,前端用它再拼一次签名调起收银台。这次签名用的是商户私钥,签名串是appId\ntimeStamp\nnonceStr\nprepay_id=xxx\n,注意最后一行是prepay_id=开头,不是裸的 prepay_id。实例里JsapiPay类专门处理这一步,返回给前端timeStamp、nonceStr、package、signType、paySign五个字段。
3.2 回调验签与 AES-256-GCM 解密
支付完成后微信会回调你的notify_url,请求体是加密的 JSON。处理流程分两步:先验签,再解密。验签用的是请求头里的Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature和Wechatpay-Serial,拼成签名串后用平台证书公钥验。
$verifyStr = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; $pubKey = openssl_pkey_get_public(file_get_contents($certPath)); $ok = openssl_verify($verifyStr, base64_decode($signature), $pubKey, OPENSSL_ALGO_SHA256); if ($ok !== 1) { // 验签失败,直接返回 401,不要继续处理 http_response_code(401); exit; }验签通过后,取resource.ciphertext、resource.nonce、resource.associated_data做 AES-256-GCM 解密,得到明文订单信息。这里有个血泪经验:解密用的密钥是 APIv3 密钥,不是商户私钥,也不是平台证书里的公钥。APIv3 密钥是你在商户平台单独设置的 32 位字符串,设置后只能重置不能查看,忘了就得重置并重新部署。
解密后拿到out_trade_no和transaction_id,更新订单状态,然后返回{"code":"SUCCESS","message":"成功"}。注意返回体必须是这个格式,HTTP 状态码 200,否则微信会按策略重试,重试多次后可能触发告警。
3.3 退款与查单的接口调用
退款走POST /v3/refund/domestic/refunds,参数包括out_trade_no或transaction_id、out_refund_no、amount.refund、amount.total、amount.currency。查单走GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid=xxx。这两个接口的签名逻辑和下单一致,实例里复用同一个Client。
// 退款 $refund = $client->post('/v3/refund/domestic/refunds', [ 'out_trade_no' => $orderNo, 'out_refund_no' => 'REFUND_' . time(), 'amount' => ['refund' => 990, 'total' => 990, 'currency' => 'CNY'], ]); // 查单,注意 GET 请求的 query 要参与签名 $query = $client->get('/v3/pay/transactions/out-trade-no/' . $orderNo, ['mchid' => $merchantId]);参数说明:退款金额不能大于订单总额,out_refund_no同一订单下不能重复。查单的 GET 请求,签名串里的 URL 路径要包含 query string,也就是/v3/pay/transactions/out-trade-no/ORDER_xxx?mchid=123,只写路径不写 query 会验签失败。这是 GET 和 POST 在签名上的主要区别。
4. 避坑与排查:那些让我加班到凌晨的报错
4.1 签名失败:先看换行和路径
现象:所有请求返回 401,错误信息SIGN_ERROR或verify failed。原因九成是签名串拼接问题。排查顺序:第一,确认五行顺序是方法、路径、时间戳、随机串、请求体;第二,确认每行末尾都有\n,包括最后一行;第三,确认路径不带域名、带 query;第四,确认请求体是实际发送的字符串,不是数组。解决:把签名串打印出来和官方文档逐字符比对,重点看换行符是不是被编辑器转成了\r\n。
4.2 平台证书轮换导致验签突然失效
现象:昨天还好好的回调,今天全部验签失败,日志里Wechatpay-Serial是个没见过的序列号。原因:微信平台证书轮换了,你本地只缓存了旧证书。解决:回调处理时根据Wechatpay-Serial去本地证书目录找对应文件,找不到就触发一次证书下载再重试。实例里CertificateManager做了这个兜底,但要注意下载接口本身也要签名,别在验签逻辑里递归调用。
4.3 APIv3 密钥设置后忘记,解密全失败
现象:验签通过,但解密报aes-gcm decrypt failed或得到乱码。原因:APIv3 密钥不对。这个密钥在商户平台设置后不可查看,很多人设置完没记,或者用了 API 密钥(v2 那个)去解密。解决:确认用的是 32 位 APIv3 密钥,不是 32 位 API 密钥,两者不是一个东西。实在不确定就重置 APIv3 密钥,重置后所有依赖它的解密和证书下载都要用新值。
4.4 回调重复处理导致订单状态错乱
现象:同一笔订单被处理多次,库存扣了两次,或者状态从「已支付」被改回「待支付」。原因:微信回调会重试,你的接口没有做幂等。解决:用out_trade_no或transaction_id做唯一索引,处理前先查订单状态,已处理直接返回成功。实例里在更新订单前加了SELECT ... FOR UPDATE和状态判断,避免并发重复。
4.5 证书路径在 Windows 和 Linux 下不一致
现象:本地 Windows 调试正常,部署到 Linux 报openssl_pkey_get_private failed。原因:路径分隔符和文件权限。Windows 用反斜杠,Linux 用正斜杠;另外私钥文件权限如果是 644,某些环境会拒绝读取。解决:用__DIR__ . '/cert/apiclient_key.pem'拼绝对路径,部署后chmod 600私钥文件。实例里路径统一走配置,不硬编码。
5. 进阶:把签名和证书封装成可测试的组件
跑通主链路之后,真正影响维护成本的是怎么组织代码。我见过太多项目把签名逻辑散落在各个控制器里,改一个参数要全局搜。这份实例的做法是把签名、验签、解密、证书管理拆成独立类,每个类只依赖配置,不依赖框架。这样你可以在 CLI 里直接跑单元测试,不用起 Web 服务。
具体技巧是给Signer类加一个「调试模式」,开启后把每次生成的签名串和待验签串写到日志。线上关掉,排查时临时打开。下面是一个最小可测的签名方法:
class Signer { public function sign(string $method, string $url, string $body, string $privateKey): string { $timestamp = time(); $nonce = bin2hex(random_bytes(16)); $message = $method . "\n" . $url . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; openssl_sign($message, $sig, $privateKey, OPENSSL_ALGO_SHA256); return sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"', $this->mchId, $nonce, $timestamp, $this->serialNo, base64_encode($sig) ); } }参数说明:$message就是签名串,$privateKey是openssl_pkey_get_private返回的资源或 PEM 字符串。Authorization头的格式固定,mchid、nonce_str、timestamp、serial_no、signature五个字段缺一不可,顺序也要一致。测试时可以用固定的时间戳和随机串,断言生成的签名串和预期一致,这样换环境也能快速定位是代码问题还是配置问题。
验证方法上,我习惯先用微信官方的「API 调试工具」发一笔 1 分钱的测试单,拿到真实的回调报文,再拿这份报文去跑本地的验签和解密逻辑。比对着文档空想快得多。另外,平台证书下载接口返回的证书有有效期,实例里加了一个定时任务每天检查一次,快过期就重新下载,避免轮换时手忙脚乱。
从那以后我每次接新的支付渠道,都强制先把签名和验签写成可单测的纯函数,再往上搭业务。支付这东西,玄学报错太多,能靠日志和单测定位的,就别靠猜。希望帮到你。
本文还有配套的精品资源,点击获取