资讯详情

PHP微信支付V3:SHA256-RSA签名与GCM回调解密

📅 2026/10/26 3:57:23 | 华诺云谱 👁 阅读
PHP微信支付V3:SHA256-RSA签名与GCM回调解密
简介面向具备PHP基础的开发者这份PHP微信支付V3案例围绕商户端支付接入展开兼顾V2部分常用能力可用于订单创建、支付验证、退款申请、回调通知与状态查询等场景也适合作为OAuth2.0授权、RSA2048证书管理及异步通知验签的实践参考。压缩包共15个文件以14个PHP源码和1个README说明为主整体约42KB目录中可看到支付、退款、查询、通知、转账、红包及JSAPI、Native、H5等入口脚本配套README便于先梳理调用关系再进入代码。已有2013人学习说明其在微信支付V3接入上的参考价值较受关注。读者可从中了解预支付下单、二维码或链接展示、回调验签、退款与查询的完整链路并借鉴配置组织、日志记录和异常处理思路降低从文档到可运行示例之间的落地成本。1. 从一次「签名错误」说起这套案例解决的到底是什么问题某开发者接了个 PHP 商城项目商户号刚下来拿着 V2 的思路拼 XML、算 MD5往 V3 接口上一怼返回永远是一行 401 SIGN_ERROR。这不是一个人的翻车现场——PHP 微信支付 V3 把签名整体换成了 SHA256-RSA2048回调还要用 AES-256-GCM 解密XML 时代的代码片段根本对不上。这份案例给的是一个能跑起来的最小工程把下单、调起支付、回调验签解密、退款对账这条链路完整走一遍不套官方 SDK一个 Client 管发请求一个 Signer 管签名一个 Decryptor 管解密商户证书和 APIv3 密钥放配置里。适合两种人手上有 PHP 老项目要接支付、得看清每一步到底在干什么的接是接上了但被签名和回调坑过、想找份能对着抄的骨架的。下面按骨架、签名、主链路、踩坑、二次开发的顺序拆开讲。2. 项目骨架与技术选型不套官方 SDK 的最小可跑结构拿到一个支付案例先别急着看下单接口。我习惯先翻目录因为目录结构基本决定了这个项目是「能改」还是「只能跑」。市面上不少 PHP 支付 demo 把所有逻辑塞进一个pay.php签名、请求、解析、写库全在两百行里第一次能跑通第二次要加个退款就得重写。这份案例的取舍正好相反它把「协议层」和「业务层」分开了代价是多几个文件好处是每层都能单独测。2.1 目录怎么摆配置、证书、协议、业务四层分离先看骨架。下面是这个案例的典型目录组织方式实际项目里文件名可能略有差异但分层思路是一致的wechatpay-v3-demo/ ├── config/ │ └── wechat.php # 商户号、appid、证书路径、APIv3 密钥 ├── cert/ │ ├── apiclient_cert.pem # 商户 API 证书 │ ├── apiclient_key.pem # 商户 API 私钥 │ └── wechatpay_pub.pem # 平台证书或微信支付公钥 ├── src/ │ ├── Signer.php # 请求签名 │ ├── Verifier.php # 应答/回调验签 │ ├── Decryptor.php # AES-256-GCM 解密 │ ├── CertificateStore.php # 证书加载与序列号索引 │ ├── Client.php # HTTP 封装 统一验签 │ ├── ApiException.php │ └── Api/ │ ├── JsapiPay.php │ ├── NativePay.php │ ├── Query.php │ ├── Close.php │ └── Refund.php ├── public/ │ ├── pay.php # 发起支付 │ └── notify.php # 支付回调入口 └── composer.json这个分层最关键的一条线是src/下面除了Client.php任何文件都不应该出现curl_*函数Api/下面任何文件都不应该出现openssl_*函数。签名归签名传输归传输业务归业务。我第一次照着这个原则重构一个老项目时最直观的收益是排查问题——401 的时候只需要看Signer500 的时候只需要看Client解密失败只需要看Decryptor不会像以前那样在一坨代码里靠var_dump猜。cert/目录单独拎出来是因为证书文件的权限和部署方式跟代码不一样。私钥文件权限我一般设成600属主是 PHP-FPM 的运行用户并且在.gitignore里排除掉线上用环境变量注入路径。这一点很多人第一次部署会漏把apiclient_key.pem跟着代码一起推到仓库里后面换证书的时候满世界找不到是谁在用。config/wechat.php里通常只有六七个字段字段本身比字段值重要配置项说明常见错误mch_id商户号纯数字字符串当成 int 存前导零丢失app_id公众号/小程序 AppID与下单接口里的 appid 不一致serial_no商户 API 证书序列号手抄时大小写或空格出错private_key_path商户私钥路径指向了证书文件而非私钥api_v3_keyAPIv3 密钥32 字节与 API 密钥混淆notify_url回调地址内网地址、http 协议api_v3_key和「API 密钥」是两个完全不同的东西前者是 32 位字符串专门用来做 AES-256-GCM 对称解密后者是 V2 时代算 MD5 用的。这两个搞混是新手最高频的翻车点之一因为控制台上两个东西挨得很近。2.2 为什么用 openssl 直连而不是再包一层框架有人会问既然有官方 SDK为什么还要自己写一遍。答案不在于「造轮子」而在于这个案例的定位是教学骨架它要让你看到每一段待签名串是怎么拼出来的。官方 SDK 把这些细节封进了Wechatpay-Signature的构造器里跑是能跑但出问题时你手上没有抓手。技术选型上这个案例只依赖 PHP 自带的openssl扩展和curl扩展加上jsoncomposer.json里基本是空的。这个选择有几条实际理由第一openssl_sign和openssl_verify原生支持OPENSSL_ALGO_SHA256V3 要求的 SHA256-RSA2048 正好对上不需要额外的加密库。第二openssl_decrypt支持aes-256-gcm回调解密也是一行函数的事。第三不引依赖意味着你可以直接把src/拷进任何一个老框架——ThinkPHP 5、Laravel 8、CI 3 都行不用管 composer 版本冲突。需要确认的环境条件只有两条起手先跑一遍php -v php -m | grep -E openssl|curl|json这里要看到openssl和curl都在列表里。如果openssl没开openssl_sign会直接报函数不存在如果curl没开Client里的curl_init会 fatal。另外 PHP 7.4 以下跑openssl_decrypt的 GCM 模式需要留意 7.1 之前的 tag 参数传递方式差异稳妥起见按 7.4 起步。2.3 证书加载一次性读进内存别在每次请求里 fopen证书加载看着简单实际上是个隐形的性能坑。有些人写openssl_sign($msg, $sig, file_get_contents($keyPath))每次请求读一次磁盘QPS 一上来 IO 就顶不住。更麻烦的是openssl_pkey_get_private失败时返回false直接传给openssl_sign会抛一个很难懂的错误。我一般这么写?php declare(strict_types1); final class CertificateStore { /** var arraystring,string 序列号 公钥 PEM */ private array $platformKeys []; private $merchantKey; public function __construct(private array $config) { // 商户私钥只解析一次失败立刻暴露 $pem file_get_contents($config[private_key_path]); if ($pem false) { throw new RuntimeException(私钥文件读取失败 . $config[private_key_path]); } $this-merchantKey openssl_pkey_get_private($pem); if ($this-merchantKey false) { throw new RuntimeException(私钥解析失败检查是否为 PKCS#8 格式 . openssl_error_string()); } // 平台侧公钥可能是单文件也可能是多证书目录 foreach ((array) $config[platform_cert_paths] as $path) { $certPem file_get_contents($path); $info openssl_x509_parse($certPem); // 序列号转大写十六进制V3 的 Wechatpay-Serial 就是这个值 $serial strtoupper($info[serialNumberHex] ?? ); $this-platformKeys[$serial] openssl_pkey_get_details( openssl_pkey_get_public($certPem) )[key]; } } public function merchantPrivateKey() { return $this-merchantKey; } public function publicKeyBySerial(string $serial): string { $serial strtoupper(trim($serial)); if (!isset($this-platformKeys[$serial])) { throw new RuntimeException(未找到序列号为 . $serial . 的平台证书可能未更新); } return $this-platformKeys[$serial]; } }这段代码有三个点值得说。openssl_pkey_get_private的返回值必须判false因为私钥文件被换掉、权限不对、或者误传了证书文件都会在这一步失败早失败比晚失败好。openssl_x509_parse返回的serialNumberHex就是 V3 应答头里Wechatpay-Serial对应的值用它做索引验签时按序列号取公钥天然支持新旧证书并存。最后把公钥提取成纯 PEM 字符串存下来比存resource更好管理也方便做缓存。提示证书是有有效期的平台证书一般一年左右轮换一次。生产环境里如果验签突然大面积报「未找到序列号」先去看Wechatpay-Serial是不是换成了一个新的值。3. 签名与验签SHA256-RSA2048 待签名串拼接与四个响应头整个 V3 接入里签名是唯一一个「差一个换行符就全废」的环节。它不像 V2 的 MD5 那样可以靠拼参数顺序试出来V3 的待签名串是严格按行组织的多一个\n、少一个\n、URL 里 query 顺序不对都会得到 401。好消息是规则本身很简单只要理解一次后面所有接口都是同一套。3.1 待签名串怎么拼五行字符串一个都不能错请求签名串的构造规则是五行每行以\n结尾最后一行也要有HTTP请求方法\n URLpath query不含协议和域名\n 时间戳秒级\n 随机串32 位以内\n 请求报文主体GET 请求为空字符串\n写成函数就是这样?php declare(strict_types1); final class Signer { public function __construct( private string $mchId, private string $serialNo, private $privateKey ) {} /** * $urlPath 必须是 path query比如 /v3/pay/transactions/out-trade-no/123?mchid1900000001 * GET 请求的 $body 传空字符串但结尾的 \n 不能省 */ public function buildMessage(string $method, string $urlPath, string $timestamp, string $nonce, string $body): string { return $method . \n . $urlPath . \n . $timestamp . \n . $nonce . \n . $body . \n; } public function sign(string $message): string { $ok openssl_sign($message, $rawSignature, $this-privateKey, OPENSSL_ALGO_SHA256); if (!$ok) { throw new RuntimeException(签名失败 . openssl_error_string()); } return base64_encode($rawSignature); } }buildMessage里最容易出错的是第二个参数。很多人直接把完整 URLhttps://api.mch.weixin.qq.com/v3/...传进来结果签名永远对不上——待签名串里的 URL 必须是去掉协议和域名的路径部分。如果是查询类接口query string 必须原样带上而且要和实际发出去的 URL 完全一致包括参数顺序。我见过一次是因为代码里先http_build_query生成 query然后发请求时又用 cURL 的CURLOPT_POSTFIELDS改了一遍参数顺序签名串和实际 URL 就对不上了这种问题看日志根本看不出来。openssl_sign的第四个参数必须显式写OPENSSL_ALGO_SHA256。这个函数默认算法是 SHA1不传就走默认值签出来的东西微信那边一秒就能识别出来是错的。这是个典型的「不报错但结果不对」的坑血泪经验。3.2 Authorization 头的组装与时间戳有效期签名算出来之后要塞进 HTTP 头。V3 要求的格式是一段带参数的字符串字段顺序可以变但字段名和引号不能省public function authorizationHeader(string $method, string $urlPath, string $body): string { $timestamp (string) time(); $nonce bin2hex(random_bytes(16)); $message $this-buildMessage($method, $urlPath, $timestamp, $nonce, $body); $signature $this-sign($message); return sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%s,serial_no%s, $this-mchId, $nonce, $signature, $timestamp, $this-serialNo ); }四个参数的含义分别是mchid是商户号nonce_str是随机串用random_bytes生成比mt_rand靠谱32 位以内即可signature是上面 base64 之后的签名值timestamp是秒级时间戳serial_no是商户 API 证书的序列号不是平台证书的序列号——这两个序列号在同一个请求里同时出现一个在请求头一个在应答头非常容易搞混。时间戳有个 5 分钟的容忍窗口。服务器时间如果和标准时间偏差超过这个范围微信侧会直接判定签名过期。容器环境里这个坑很常见因为有些基础镜像不带时区数据容器启动后系统时间和宿主机不一致。判断方法很简单date一下看和本地时间差多少超过一分钟就该处理了。注意serial_no里不要有空格也不要带0x前缀。从证书里用openssl x509 -serial -noout -in apiclient_cert.pem拿到的值形如serial5A1B2C3D...取等号后面那一段转成大写。3.3 应答验签四个响应头 报文主体顺序不能乱请求发出去之后微信回来的每一个 2xx 和 4xx 响应都带签名这个签名必须验。跳过验签的代码能跑但等于把「回调是真还是假」这件事交给了运气。验签串的构造规则和请求不一样是三行应答时间戳\n 应答随机串\n 应答报文主体\n对应的实现final class Verifier { private const MAX_SKEW 300; // 5 分钟 public function verify( string $timestamp, string $nonce, string $body, string $signature, string $platformPublicKey ): bool { if ($timestamp || $nonce || $signature ) { throw new RuntimeException(验签头缺失检查网关是否吞掉了 Wechatpay-* 头); } if (abs(time() - (int) $timestamp) self::MAX_SKEW) { throw new RuntimeException(应答时间戳偏差过大 . $timestamp); } $message $timestamp . \n . $nonce . \n . $body . \n; $result openssl_verify( $message, base64_decode($signature, true), $platformPublicKey, OPENSSL_ALGO_SHA256 ); if ($result -1) { throw new RuntimeException(验签过程出错 . openssl_error_string()); } return $result 1; } }三个参数按顺序对应Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature公钥按Wechatpay-Serial从CertificateStore里取。这里有个很容易忽略的返回值语义openssl_verify返回1表示通过0表示签名不匹配-1表示验签过程本身出错比如公钥格式不对。很多示例代码写成if (openssl_verify(...) ! 1) throw把-1和0混在一起排查时就会误判成「签名算法错」而实际是「公钥加载错」。还有一点$body必须是原始响应字符串不能用json_decode再json_encode回来的结果。JSON 重新编码会改变空格、转义、Unicode 的处理方式字节一变验签必然失败。所以Client里必须先把 header 和 body 拆开body 原样保留。3.4 平台证书与公钥模式两条路的取舍平台侧公钥的来源有两种。老一点的做法是调用/v3/certificates接口下载平台证书接口返回的证书内容本身是加密的要用 APIv3 密钥解开才能拿到 PEM新一点的做法是直接用「微信支付公钥」商户在后台下载一个公钥文件配一个公钥 ID验签时按这个 ID 取。两条路在代码里的差别只体现在CertificateStore这一层证书模式下按Wechatpay-Serial去索引公钥模式下按Wechatpay-Serial匹配公钥 ID。业务代码完全不用改。我一般的做法是把这一层抽成接口interface PlatformKeyProvider { public function getBySerial(string $serial): string; }然后写CertificateProvider和PublicKeyProvider两个实现配置里一个开关切换。这样后面平台真换方案的时候只改一行配置业务层一行不动。至于证书的自动下载轮换我的建议是别在业务请求链路里做单独挂一个定时任务每小时拉一次写进缓存业务侧只读缓存。放在请求链路里的后果是证书过期的那一刻所有并发请求同时去拉证书接口被打爆然后整站支付不可用。4. 支付主链路JSAPI/Native 下单、查询、AES-256-GCM 回调解密前面两章搭的是地基这一章开始铺管道。一个完整的支付闭环包含四段下单拿预支付标识、前端调起、异步回调确认、后续查询退款。四段里任何一段断了用户看到的就是「付了钱但订单没变」。这份案例把四段拆成四个类共用同一个Client所以传输层和签名层只需要写一次。4.1 Client一个请求方法管住所有接口先看Client的核心它是整个案例里唯一碰curl的地方final class Client { public function __construct( private Signer $signer, private Verifier $verifier, private PlatformKeyProvider $keys, private string $baseUri https://api.mch.weixin.qq.com ) {} public function request(string $method, string $path, array $payload []): array { $body $payload [] ? : json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $ch curl_init($this-baseUri . $path); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST $method, CURLOPT_RETURNTRANSFER true, CURLOPT_HEADER true, // 需要拿到响应头做验签 CURLOPT_CONNECTTIMEOUT 5, CURLOPT_TIMEOUT 10, CURLOPT_HTTPHEADER [ Authorization: . $this-signer-authorizationHeader($method, $path, $body), Accept: application/json, Content-Type: application/json, ], ]); if ($body ! ) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $raw curl_exec($ch); $errno curl_errno($ch); $code (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $hsize (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); if ($errno ! 0) { throw new RuntimeException(网络层失败errno . $errno); } $headers $this-parseHeaders(substr($raw, 0, $hsize)); $content substr($raw, $hsize); // 2xx / 4xx 都必须验签只有网关级 5xx 可以跳过 if ($code 200 $code 500) { $ok $this-verifier-verify( $headers[wechatpay-timestamp] ?? , $headers[wechatpay-nonce] ?? , $content, $headers[wechatpay-signature] ?? , $this-keys-getBySerial($headers[wechatpay-serial] ?? ) ); if (!$ok) { throw new RuntimeException(应答验签失败); } } $data json_decode($content, true) ?: []; if ($code 400) { throw new ApiException($data[code] ?? UNKNOWN, $data[message] ?? , $code); } return $data; } }几个参数值得单独说。CURLOPT_HEADER true是必须的因为要拿响应头验签很多人用CURLOPT_HEADERFUNCTION回调收集头也行但要注意别把HTTP/1.1 100 Continue那种中间响应头混进来。CURLINFO_HEADER_SIZE用来切分头和体的边界比自己找\r\n\r\n稳。超时设 5 秒连接、10 秒总时长是因为支付接口本身响应很快卡住基本就是网络问题早点失败比挂着强。parseHeaders里要做两件事按\r\n切开冒号切 key/value然后 key 全部转小写。PHP 的getallheaders()在不同 SAPI 下返回的大小写不一致回调入口也一样统一转小写是最省心的做法。ApiException里带上code、message和 HTTP 状态码是因为微信的错误码分两层HTTP 401 可能是SIGN_ERROR也可能是PARAM_ERRORHTTP 403 可能是NOT_ENOUGH余额不足。日志里只打状态码事后根本查不出原因。4.2 JSAPI 下单拿到 prepay_id 还要再做一次签名JSAPI 是最常接的一种公众号内 H5 和小程序都走这条。下单请求体长这样$resp $client-request(POST, /v3/pay/transactions/jsapi, [ appid $config[app_id], mchid $config[mch_id], description 会员月度订阅, out_trade_no $outTradeNo, // 商户侧唯一单号32 位以内 notify_url https://pay.example.com/notify.php, time_expire date(c, time() 1800), // 30 分钟后过期 amount [total 990, currency CNY], // 单位分 payer [openid $openid], attach json_encode([uid $uid], JSON_UNESCAPED_UNICODE), ]); $prepayId $resp[prepay_id];参数里最容易错的是amount.total单位是分999 表示 9.99 元。这个单位在整条链路里统一是分包括退款金额、查询返回金额、对账单里的金额。我见过的翻车是下单传了元9.9结果用户付了 9.9 元落库时又按元算了一遍账对不上。稳妥做法是在业务层明确传整数分命名带_fen后缀。out_trade_no必须是商户侧唯一长度 6-32 位只能包含字母数字和_-|*。用date(YmdHis) . mt_rand(1000, 9999)这种拼法在低并发下没事但秒杀场景会撞。我一般用雪花 ID 或者uniqid加随机后缀。attach是个好用的字段可以塞自己的业务标识回调里会原样返回省一次数据库查询。注意长度限制 128 字符塞 JSON 的时候别塞太多。拿到prepay_id之后还没完前端WeixinJSBridge或wx.requestPayment需要的是一组签名参数这组参数要用商户私钥再签一次function buildJsapiParams(string $appId, string $prepayId, $merchantKey): array { $timestamp (string) time(); $nonce bin2hex(random_bytes(16)); $package prepay_id . $prepayId; // 小程序/公众号调起签名串appId\ntimeStamp\nnonceStr\npackage\n $message $appId . \n . $timestamp . \n . $nonce . \n . $package . \n; openssl_sign($message, $raw, $merchantKey, OPENSSL_ALGO_SHA256); return [ appId $appId, timeStamp $timestamp, nonceStr $nonce, package $package, signType RSA, paySign base64_encode($raw), ]; }这段是最容易出问题的地方因为它的签名串和请求签名串格式完全不同——四行appId在前package必须是prepay_id前缀。signType固定RSA写MD5是 V2 的习惯会直接调不起来。前端报「支付签名验证失败」基本就是这个函数的问题跟后端下单接口无关。还有一个细节timeStamp是字符串前端接收后别做数字转换有些前端框架会把它当 Number 传回长度变化后签名对不上。4.3 Native 扫码、订单查询与关单Native 支付给的是二维码链接比 JSAPI 简单但轮询逻辑更麻烦$resp $client-request(POST, /v3/pay/transactions/native, [ appid $config[app_id], mchid $config[mch_id], description 扫码支付测试, out_trade_no $outTradeNo, notify_url https://pay.example.com/notify.php, amount [total 1, currency CNY], ]); $codeUrl $resp[code_url]; // weixin://wxpay/bizpayurl?prxxxxxcode_url转成二维码给前端展示。用户扫码之后服务端靠回调或轮询确认结果。轮询写法// GET 请求path 里带 query签名串的 URL 部分要和它完全一致 $path /v3/pay/transactions/out-trade-no/ . $outTradeNo . ?mchid . $config[mch_id]; $info $client-request(GET, $path); if (($info[trade_state] ?? ) SUCCESS) { // 支付成功 }注意这里 GET 请求的$path必须原样传给Client因为Client内部既用它拼 URL也用它拼签名串。如果业务层传了不带 query 的 pathClient里再拼 query签名就对不上。这是我定的一条规矩path 只由业务层构造一次往下传到底谁都不许改。trade_state的取值需要认得全SUCCESS支付成功、REFUND转入退款、NOTPAY未支付、CLOSED已关闭、REVOKED已撤销、USERPAYING用户支付中、PAYERROR支付失败。轮询逻辑里USERPAYING要继续等NOTPAY可以等超时后关单CLOSED和PAYERROR直接结束。超时没付的单要主动关不然会占着单号$client-request(POST, /v3/pay/transactions/out-trade-no/ . $outTradeNo . /close, [ mchid $config[mch_id], ]);关单接口的请求体里只有mchid一个字段注意别漏漏了会返回PARAM_ERROR。4.4 回调通知验签、AES-256-GCM 解密、幂等落库回调是整个链路里最需要小心的一环因为它是「钱到没到」的唯一权威来源而且会被重复推送。先看回调报文的形状{ id: EV-2018022511223320873, create_time: 2015-05-20T13:29:3508:00, event_type: TRANSACTION.SUCCESS, resource_type: encrypt-resource, resource: { algorithm: AEAD_AES_256_GCM, ciphertext: Base64编码的密文, associated_data: transaction, nonce: 随机串, original_type: transaction } }真正的订单信息在resource.ciphertext里是加密的。解密用 APIv3 密钥算法 AES-256-GCMfinal class Decryptor { public function __construct(private string $apiV3Key) { if (strlen($apiV3Key) ! 32) { throw new RuntimeException(APIv3 密钥必须是 32 字节); } } public function decrypt(string $associatedData, string $nonce, string $ciphertextBase64): string { $cipher base64_decode($ciphertextBase64, true); if ($cipher false || strlen($cipher) 16) { throw new RuntimeException(密文格式不对); } // GCM 模式下密文末尾 16 字节是认证 tag $tag substr($cipher, -16); $data substr($cipher, 0, -16); $plain openssl_decrypt( $data, aes-256-gcm, $this-apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $associatedData ); if ($plain false) { throw new RuntimeException(解密失败 . openssl_error_string()); } return $plain; } }这段代码里最容易踩的是 tag 的处理。PHP 的openssl_decrypt在 GCM 模式下第六个参数是引用传的 tag而且只接受 16 字节的原始二进制不是 base64。微信返回的ciphertext是把「密文 16 字节 tag」一起 base64 的所以要先把整体 base64 解码再从尾部切 16 字节出来。不少示例代码直接把整个 base64 丢给openssl_decrypt然后抱怨解密结果是 false。第七个参数$associatedData也不能漏。它是resource.associated_data通常就是字符串transactionAAD 参与认证漏了或传错同样解不出来。回调入口的完整流程// public/notify.php $raw file_get_contents(php://input); $headers array_change_key_case(getallheaders(), CASE_LOWER); // 1. 验签用平台公钥按 Wechatpay-Serial 取 $ok $verifier-verify( $headers[wechatpay-timestamp] ?? , $headers[wechatpay-nonce] ?? , $raw, $headers[wechatpay-signature] ?? , $keys-getBySerial($headers[wechatpay-serial] ?? ) ); if (!$ok) { http_response_code(401); echo json_encode([code FAIL, message 验签失败]); exit; } $notice json_decode($raw, true); $res $notice[resource]; $plain $decryptor-decrypt($res[associated_data], $res[nonce], $res[ciphertext]); $order json_decode($plain, true); // 2. 幂等以 transaction_id 或 out_trade_no 做唯一键 if ($repo-alreadyHandled($order[out_trade_no])) { echo json_encode([code SUCCESS]); // 已经处理过也要回 SUCCESS exit; } // 3. 校验金额防止串单 $local $repo-findByOutTradeNo($order[out_trade_no]); if (!$local || $local[amount_fen] ! $order[amount][total]) { echo json_encode([code FAIL, message 金额不一致]); exit; } // 4. 落库 改订单状态同一个事务里 $repo-markPaid($order[out_trade_no], $order[transaction_id], $order[success_time]); echo json_encode([code SUCCESS]);四个步骤的顺序不能变。验签必须在解密之前否则任何人都能往你的接口推一段加密数据幂等判断必须在业务处理之前因为回调是至少一次语义同一笔订单可能推好几次金额校验是为了防止有人用自己账号的合法回调去刷别人的订单。最后返回的{code:SUCCESS}是告诉微信「收到了别再推」如果返回失败或者 HTTP 非 200微信会按 15 秒、15 秒、30 秒、3 分钟、10 分钟……逐级拉长间隔重试24 小时内最多重试若干次——具体次数不去背它反正你不回 SUCCESS 它就会一直来。注意getallheaders()在 FPM 下能用在 CLI 或者某些精简 SAPI 下没有稳妥起见可以自己从$_SERVER里拼HTTP_WECHATPAY_SIGNATURE之类的键。5. 避坑与排查签名失败、证书过期、回调重放的现场记录这一章是我自己踩过的、也帮别人排查过的五类问题。之所以单独拿出来是因为 V3 的错误信息大多很含糊SIGN_ERROR下面是七八种可能的原因没有一套排查路径很容易在错误的方向上耗掉一整天。下面每条按「现象 → 原因 → 解决」写并且尽量给出可以直接照着跑的诊断命令。5.1 坑一所有接口都返回 401 SIGN_ERROR现象请求发出去HTTP 401响应体是{code:SIGN_ERROR,message:签名错误}。换成官方示例代码也一样换商户号也一样看起来像是环境问题。原因九成是待签名串和实际发出的请求不一致。常见有三种一是 URL 部分带了域名二是 GET 请求的 body 传了null而不是空字符串导致拼出来变成null而不是空行三是请求体用json_encode生成了两次签名时用的是 A 版本发送时用的是 B 版本两个版本因为JSON_UNESCAPED_UNICODE参数不同而字节不同。解决先把待签名串打印出来逐行核对。写一个临时函数把$message用str_replace(\n, \\n\n, $message)打出来看每一行的末尾有没有多空格。然后确认Client里用来签名的$body和CURLOPT_POSTFIELDS传的是同一个变量不要重新编码。诊断命令// 临时加在 Client::request 里只在开发环境开 error_log([V3-DEBUG] method{$method} path{$path} body{$body}); error_log([V3-DEBUG] message\n . str_replace(\n, \\n\n, $message));打出来的东西和官方文档的示例对一遍五行结构、\n位置、URL 里有没有https://基本一眼就能看出来。5.2 坑二验签在本地全过上线全挂现象本地环境跑得好好的部署到线上之后所有应答验签都失败日志里是「未找到序列号为 XXX 的平台证书」或者「验签头缺失」。原因两种情况。一是线上的反向代理或者网关把Wechatpay-*响应头吃掉了PHP 侧压根拿不到二是线上的平台证书还是旧的而微信已经换了新序列号的证书。解决先确认头有没有到 PHP 层。在Client里打印$headers的键名如果只有content-type、server之类说明是网关的问题去改 Nginx 的proxy_pass_header或者underscores_in_headers相关配置。如果头在但序列号对不上就需要更新平台证书。检查证书序列号的命令openssl x509 -in cert/wechatpay_pub.pem -serial -noout # serial5A1B2C3D4E5F...拿这个值和日志里报的Wechatpay-Serial比一下不一致就是证书没更新。生产环境的正确做法是把证书刷新做成定时任务而不是等出问题再手动换。5.3 坑三回调收不到日志里一条记录都没有现象用户付款成功前端也提示支付完成但后台订单状态一直是待支付notify.php的访问日志里空空如也。原因notify_url的问题居多。常见的有填了http://而不是https://填了内网地址或带端口的内网服务URL 里带了会被 WAF 拦掉的参数或者回调地址做了 302 跳转。V3 要求回调地址是公网可访问的 HTTPS 地址跳转是不跟的。解决先用 curl 从外网机器直接 POST 一下自己的回调地址看能不能通curl -i -X POST https://pay.example.com/notify.php \ -H Content-Type: application/json \ -H Wechatpay-Timestamp: 1700000000 \ -H Wechatpay-Nonce: testnonce \ -H Wechatpay-Signature: test \ -H Wechatpay-Serial: test \ -d {id:test}能返回 401 说明链路通了业务逻辑里验签失败是正常的因为签名是假的。如果 curl 直接超时或者 502那就是网络层的问题跟代码无关。另外记得回调入口不要加登录态校验有些框架的全局中间件会给所有路由套 session回调请求没有 cookie直接被踢成 302微信那边看到的就是失败。5.4 坑四同一笔订单被处理了两次库存扣成负数现象数据库里同一个out_trade_no有两条支付成功记录或者积分加了两遍。原因没有做幂等。微信的回调是至少一次语义网络抖动、你的接口响应慢、返回体格式不对比如返回了{code:SUCCESS}但 HTTP 状态是 500都会触发重推。如果业务处理是先查后写中间没有唯一约束并发重推就会两条都进去。解决数据库层面加唯一索引业务层面用「插入成功才继续」的模式。表结构大致是CREATE TABLE pay_notify_log ( id bigint unsigned NOT NULL AUTO_INCREMENT, out_trade_no varchar(64) NOT NULL COMMENT 商户订单号, transaction_id varchar(64) NOT NULL COMMENT 微信支付单号, event_type varchar(64) NOT NULL, raw_body text COMMENT 原始报文排查用, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_out_trade_no_type (out_trade_no, event_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;用INSERT ...触发唯一键冲突来判断「已处理」比先SELECT再INSERT可靠得多。冲突了就直接返回 SUCCESS什么都不做。这张表除了幂等还有个附加价值raw_body存下来本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑