ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP微信支付v3服务端实战:签名验签、证书管理与回调解密避坑指南

PHP微信支付v3服务端实战:签名验签、证书管理与回调解密避坑指南 简介这份资源是面向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 KEYPHP 的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通常是certificateciphertext是 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_idxxx\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}?mchidxxx。这两个接口的签名逻辑和下单一致实例里复用同一个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?mchid123只写路径不写 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 分钱的测试单拿到真实的回调报文再拿这份报文去跑本地的验签和解密逻辑。比对着文档空想快得多。另外平台证书下载接口返回的证书有有效期实例里加了一个定时任务每天检查一次快过期就重新下载避免轮换时手忙脚乱。从那以后我每次接新的支付渠道都强制先把签名和验签写成可单测的纯函数再往上搭业务。支付这东西玄学报错太多能靠日志和单测定位的就别靠猜。希望帮到你。本文还有配套的精品资源点击获取
返回列表