PHP 微信支付 v3 完整实例:签名、回调、退款全链路避坑指南
简介:这份资源是面向PHP开发者的微信支付V3接口完整实例,适合需要为线上商城或线下场景接入微信支付、希望掌握V3新特性的中初级开发者参考。压缩包共16个文件、约61KB,以asp脚本为主,辅以php接口文件、txt说明文档、pem证书、js脚本及mdb数据文件,覆盖统一下单、前端调起、支付回调、订单查询、退款与异常处理等环节,并演示API签名、证书管理与沙箱测试的落地写法。已有4669人学习下载,说明其在同类资料中具备一定参考价值。读者可从中获得一套可运行的支付流程骨架,理解V3签名与证书机制的具体实现,并借助说明文档与示例配置快速对照自身项目进行改造,减少接入调试中的试错成本。
1. PHP 接微信支付 v3:一份能直接跑通的完整实例到底长什么样
上周有个做商城外包的朋友半夜发消息,说微信支付 v3 的签名怎么调都是 401,官方文档翻了三遍,SDK 装了卸卸了装,最后发现是私钥文件路径里多了个空格。这种事在 v3 上太常见了——接口本身不难,难的是签名、证书、回调验签这一整套链路里,任何一环错一点,报错信息都不会告诉你错在哪。这份 PHP 微信支付 v3 完整实例,就是把这些环节拆开摆平:下单、签名、回调、退款、查单,每个动作都有对应代码,不依赖框架,原生 PHP 就能跑。适合正在接微信支付 v3 的后端、被签名和证书折腾过的开发者,也适合想搞清楚 v3 和 v2 到底差在哪的人。下面按我实际拆包的顺序讲,先讲清楚它解决什么问题,再落到每一步怎么配、参数怎么填、哪里最容易翻车。
2. 先搞懂 v3 的签名链路:为什么你的请求总是 401
微信支付 v3 和 v2 最大的区别,是把「拼参数 + MD5」换成了「私钥签名 + 平台证书验签」。很多人第一次接 v3 翻车,不是代码写错,是没理解这条链路的走向:你用商户私钥对请求签名,微信侧用你上传的公钥验;微信返回的数据,你要用微信平台证书里的公钥验。两个方向、两套密钥,搞混一个就是 401 或验签失败。
2.1 请求签名到底签了什么
v3 的请求签名不是对整个 body 做哈希,而是拼一个固定格式的待签名串,再用商户私钥做 SHA256withRSA。待签名串的构造规则是五行,每行以换行符结尾:
HTTP请求方法\n URL路径\n 时间戳\n 随机串\n 请求报文主体\n这里有几个细节是文档里一笔带过、但实际会要命的:URL 路径要带 query string,比如/v3/pay/transactions/jsapi就是纯路径,但查单是/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={mchid},query 部分必须原样拼进去,不能 URL 编码两次;请求体如果是 GET,这一行就是空字符串,但换行符不能省;时间戳是秒级,和微信服务器时间差超过 5 分钟直接拒。我一般会在签名前先校一次服务器时间,date('c')对一下,差得多就先同步时间,这是最容易被忽略的玄学问题。
签名完成后,把结果 Base64 编码,拼进 Authorization 头,格式是:
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="商户号",nonce_str="随机串",signature="签名",timestamp="时间戳",serial_no="证书序列号"serial_no 是商户 API 证书的序列号,不是平台证书的,这两个序列号在证书管理页都能看到,但长得像,复制错是高频事故。
2.2 平台证书和商户证书,别拿反了
这份实例里把两类证书分得很清楚:商户证书(apiclient_cert.pem + apiclient_key.pem)用来签你的请求;平台证书(微信下发的,需要通过接口下载)用来验微信的回调和返回。新手最容易犯的错,是拿商户证书去验微信的签名,结果怎么都验不过。
平台证书的获取方式,v3 提供了下载接口,但下载请求本身又需要签名,所以第一次是「用商户私钥签一个下载请求,拿回平台证书」。实例里给了一个独立脚本做这件事,跑一次把证书存本地,后面验签直接读文件。这里有个坑:平台证书会轮换,不是下载一次就永久有效。稳妥做法是每次启动或定时任务里检查证书有效期,快过期就重新下载。实例里用的是一个简单的本地缓存 + 过期时间判断,够用但不完美,生产环境建议加个定时刷新。
<?php // 下载平台证书:用商户私钥签名请求,拿回证书列表 function downloadPlatformCerts(string $mchId, string $serialNo, string $privateKeyPath): array { $url = 'https://api.mch.weixin.qq.com/v3/certificates'; $method = 'GET'; $timestamp = time(); $nonce = bin2hex(random_bytes(16)); $body = ''; // GET 请求体为空 // 构造待签名串,注意每行都以 \n 结尾 $message = $method . "\n" . '/v3/certificates' . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; $privateKey = openssl_pkey_get_private(file_get_contents($privateKeyPath)); openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); $signature = base64_encode($signature); $auth = sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"', $mchId, $nonce, $signature, $timestamp, $serialNo ); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: ' . $auth, 'Accept: application/json'], ]); $resp = curl_exec($ch); curl_close($ch); $data = json_decode($resp, true); // 返回结构里每个证书带 serial_no 和 encrypt_certificate,需要 AES-GCM 解密 return $data['data'] ?? []; }这段代码的关键在待签名串的拼接:$method . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n",少一个\n或者多一个空格,签名就对不上。$body对 GET 是空串,但后面的\n不能省。openssl_sign的第四个参数必须显式传OPENSSL_ALGO_SHA256,不传默认是 SHA1,微信不认。返回的证书是加密的,解密用的是 APIv3 密钥,这个密钥在商户平台自己设置,32 位,别和 API 密钥搞混——APIv3 密钥只在解密回调和证书时用,不参与签名。
2.3 回调验签:别信 body 里的任何字段
支付成功回调是另一个高频翻车点。v3 的回调带Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial四个头,验签要用平台证书公钥对「时间戳\n随机串\nbody\n」做验签。注意这里待验签串的构造和请求签名不一样,是三个字段加 body,顺序固定。
实例里的回调处理分两步:先验签,验过了再解密resource字段。resource里是 AES-256-GCM 加密的,密钥就是 APIv3 密钥,associated_data和nonce都在 resource 里带着。解密后才是真正的订单信息。很多人跳过验签直接解密,本地测试能过,线上就是安全漏洞——攻击者伪造一个回调就能改订单状态。
<?php // 回调验签 + 解密 function verifyAndDecryptCallback(array $headers, string $body, string $platformCertPath, string $apiV3Key): array { $timestamp = $headers['Wechatpay-Timestamp']; $nonce = $headers['Wechatpay-Nonce']; $signature = $headers['Wechatpay-Signature']; // 待验签串:时间戳\n随机串\nbody\n $message = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; $cert = openssl_x509_read(file_get_contents($platformCertPath)); $pubKey = openssl_pkey_get_public($cert); $ok = openssl_verify($message, base64_decode($signature), $pubKey, OPENSSL_ALGO_SHA256); if ($ok !== 1) { throw new RuntimeException('回调验签失败'); } $data = json_decode($body, true); $res = $data['resource']; // AES-256-GCM 解密,key 为 APIv3 密钥 $plain = openssl_decrypt( base64_decode($res['ciphertext']), 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, base64_decode($res['nonce']), base64_decode($res['associated_data']) ); return json_decode($plain, true); }openssl_verify返回 1 才算通过,返回 0 是签名不匹配,返回 -1 是参数错误,三种情况要分开处理,别一律当失败。openssl_decrypt的 tag 参数在 GCM 模式下是必须的,这里微信把 tag 拼在了 ciphertext 末尾,所以直接传base64_decode($res['ciphertext'])就行,不用单独取 tag——这是 v3 的一个约定,文档里写得比较隐蔽。解密失败最常见的原因是 APIv3 密钥填错,或者associated_data用了 URL 编码后的值。
3. 下单到支付:JSAPI 全流程的代码落地
搞懂签名之后,下单本身反而简单。这份实例覆盖了 JSAPI、Native、H5 三种下单方式,JSAPI 最典型,因为它涉及前端调起支付,链路最长。下面按「服务端下单 → 返回参数 → 前端调起 → 回调确认」走一遍。
3.1 服务端下单:参数怎么填才不报错
JSAPI 下单接口是POST /v3/pay/transactions/jsapi,必填字段有appid、mchid、description、out_trade_no、notify_url、amount、payer.openid。其中amount.total单位是分,不是元,这个错我见过不止一次,填了 9.9 结果扣了 0.099 元。out_trade_no是商户侧订单号,同一商户号下唯一,重复下单会报ORDERPAID或OUT_TRADE_NO_USED,所以下单前最好先查一次单,或者用带时间戳的订单号。
<?php // JSAPI 下单 function jsapiOrder(array $params, string $mchId, string $serialNo, string $privateKeyPath): array { $url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi'; $body = json_encode([ 'appid' => $params['appid'], 'mchid' => $mchId, 'description' => $params['description'], 'out_trade_no' => $params['out_trade_no'], 'notify_url' => $params['notify_url'], 'amount' => ['total' => $params['total'], 'currency' => 'CNY'], 'payer' => ['openid' => $params['openid']], ], JSON_UNESCAPED_UNICODE); $timestamp = time(); $nonce = bin2hex(random_bytes(16)); $message = "POST\n/v3/pay/transactions/jsapi\n{$timestamp}\n{$nonce}\n{$body}\n"; $privateKey = openssl_pkey_get_private(file_get_contents($privateKeyPath)); openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); $auth = sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"', $mchId, $nonce, base64_encode($signature), $timestamp, $serialNo ); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: ' . $auth, 'Content-Type: application/json', 'Accept: application/json', ], ]); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { throw new RuntimeException('下单失败: ' . $resp); } return json_decode($resp, true); // 返回 prepay_id }json_encode必须带JSON_UNESCAPED_UNICODE,否则中文描述会被转成\uXXXX,虽然微信能解析,但待签名串里的 body 和你实际发出去的 body 必须完全一致,编码不一致会导致签名对不上。Content-Type和Accept两个头都要带,少一个可能返回 400。返回的prepay_id是前端调起支付的关键,有效期两小时,过期要重新下单。
3.2 前端调起:二次签名别漏了
拿到prepay_id后,服务端还要再做一次签名,给前端wx.chooseWXPay用。这次签名的待签名串是appId\ntimeStamp\nnonceStr\npackage\n,其中package是prepay_id=xxx的格式。注意这里的timeStamp是字符串,不是数字,前端传的时候别转成 int。
<?php // 生成前端调起支付参数 function buildJsapiPayParams(string $appId, string $prepayId, string $privateKeyPath): array { $timeStamp = (string)time(); $nonceStr = bin2hex(random_bytes(16)); $package = 'prepay_id=' . $prepayId; $message = $appId . "\n" . $timeStamp . "\n" . $nonceStr . "\n" . $package . "\n"; $privateKey = openssl_pkey_get_private(file_get_contents($privateKeyPath)); openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); return [ 'appId' => $appId, 'timeStamp' => $timeStamp, 'nonceStr' => $nonceStr, 'package' => $package, 'signType' => 'RSA', 'paySign' => base64_encode($signature), ]; }signType必须是RSA,v2 时代是MD5,这里写错前端直接报「签名错误」。paySign是 Base64 后的结果,不是 hex。前端拿到这组参数直接传给wx.chooseWXPay,成功回调里不要信前端说的「支付成功」,一切以服务端收到的异步回调为准——这是支付接入的铁律,前端回调只能用来做 UI 跳转。
3.3 查单和退款:状态机要对齐
支付完成后,订单状态以回调为准,但回调可能延迟或丢失,所以要有主动查单兜底。查单接口是GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={mchid},注意 query 参数要拼进待签名串的 URL 路径里。返回的trade_state有SUCCESS、REFUND、NOTPAY、CLOSED等,业务侧要按这个状态机更新订单,别自己造状态。
退款接口是POST /v3/refund/domestic/refunds,必填out_trade_no(或transaction_id)、out_refund_no、amount.refund、amount.total、amount.currency。退款是异步的,提交成功只代表受理,最终结果看退款回调或主动查退款单。实例里把退款单独封装了一个方法,和下单共用签名逻辑,只是 URL 和 body 不同——这也是 v3 的一个好处,签名逻辑可以抽成一个通用函数,所有接口复用。
4. 避坑排查:这五个地方我踩过,你别再踩
4.1 签名一直 401,但代码看起来没问题
现象:所有接口都返回 401,错误信息是SIGN_ERROR或SIGNATURE_ERROR。原因通常有三个:待签名串的换行符不对(Windows 下编辑器把\n存成了\r\n)、URL 路径里 query 参数顺序和实际请求不一致、私钥文件读取失败但没报错(openssl_pkey_get_private返回 false,openssl_sign静默失败)。解决:把待签名串打印出来,逐字节对比,确认每行结尾只有一个\n;query 参数按实际请求顺序拼;私钥读取后判断返回值是否为 false,是就抛异常。
4.2 回调验签通过但解密失败
现象:openssl_verify返回 1,但openssl_decrypt返回 false。原因基本是 APIv3 密钥不对,或者associated_data传了空字符串而实际有值。解决:确认 APIv3 密钥是 32 位、在商户平台「账户中心 → API 安全」里设置的,不是 API 密钥;associated_data直接从 resource 里取,不要自己拼。另外 GCM 模式下openssl_decrypt的 tag 参数不能省,微信把 tag 附在 ciphertext 尾部,直接整体传入即可。
4.3 平台证书过期导致验签突然全挂
现象:前一天还好好的,第二天所有回调验签失败。原因:平台证书轮换了,本地缓存的旧证书失效。解决:不要假设证书永久有效,实例里的做法是缓存时记录expire_time,每次验签前检查,过期就重新下载。更稳的做法是加一个定时任务,每天检查一次证书有效期,剩 7 天就刷新。这个坑很隐蔽,因为报错信息只说验签失败,不会告诉你证书过期。
4.4 金额单位搞错,测试环境扣了 0.01 元
现象:下单金额填 100,实际扣款 1 元。原因:amount.total单位是分,填 100 就是 1 元。解决:所有金额在业务层用元,调微信接口前统一乘 100 并取整,退款同理。建议在封装层做一个yuanToFen()函数,别在业务代码里到处乘 100,容易漏。
4.5 重复下单报 OUT_TRADE_NO_USED
现象:用户重复点击支付,第二次下单报OUT_TRADE_NO_USED。原因:out_trade_no在同一商户号下唯一,已存在的订单号不能再次下单。解决:下单前先查单,如果订单已存在且未支付,直接复用原prepay_id(两小时内有效);或者订单号带随机后缀,但这样对账会麻烦。实例里用的是「查单 + 复用」策略,配合前端按钮防抖,基本能解决。
5. 把签名逻辑抽成一层:我的复用习惯和验证方法
拆完这份实例,我最大的感受是:v3 的复杂度不在业务,在签名链路的复用。如果每个接口都手写一遍待签名串拼接,迟早会有一处漏了\n或者序列号填错。我一般会把签名抽成一个WechatPayV3Client类,构造函数吃mchId、serialNo、privateKeyPath、apiV3Key,对外只暴露request($method, $path, $body)和verifyCallback($headers, $body)两个方法,所有接口调用都走这两个入口。这样签名逻辑只有一份,改一处全生效。
验证方法上,我习惯分三层:第一层用微信提供的「签名校验工具」对单次请求做离线验证,确认待签名串和签名结果对得上;第二层在测试环境跑一遍完整链路,下单 → 支付 → 回调 → 查单,每步打印关键字段;第三层做异常注入,故意改错 APIv3 密钥、用过期证书、重复订单号,看错误处理是否按预期抛异常而不是静默失败。第三层最容易被跳过,但线上事故往往就出在「异常没被捕获」上。
还有一个细节值得单独说:时间戳。v3 要求请求时间戳和微信服务器时间差不超过 5 分钟,服务器时间漂移是低频但致命的问题。我的习惯是在客户端初始化时先请求一次微信的服务器时间(任何接口的响应头里都有Date),算出本地偏移量,签名时用「本地时间 + 偏移量」,而不是直接time()。这个偏移量缓存几分钟,避免每次请求都多一次网络往返。听起来有点过度设计,但被时间漂移坑过一次之后,我每次接支付都会加上这一步。
最后说证书序列号的获取。很多人手动从证书文件里读序列号,容易读错格式(hex 和十进制混用)。稳妥做法是用openssl_x509_parse解析证书,取serialNumber字段,再转成大写 hex。实例里给了一个小函数做这件事,跑一次把序列号打印出来,和商户平台显示的对一遍,对上了再往下走。从那以后我每次接新的商户号,都强制先跑一遍「序列号 + 时间偏移 + 平台证书下载」这三个自检,全过了才开始写下单代码——这三步花十分钟,能省掉后面几小时的 401 排查。希望帮到你。
本文还有配套的精品资源,点击获取