简介:面向 PHP 开发者的微信支付 V3 完整实例,覆盖统一下单、前端调起支付、回调通知、订单查询、退款与异常处理等核心链路,并具体涉及预支付交易会话标识获取、支付结果验证等关键步骤;同时说明 V3 版本新增的 API 签名机制、证书管理和沙箱环境测试方式,补充了安全与合规建议,适合需要快速对接最新微信支付接口的商城或后台开发者。资源包共 16 个文件,体积约 61KB,内容以 PHP 示例代码为主,另含 ASP 可参考实现、文本说明、pem 证书文件以及 jQuery 加载动画等辅助资源,demo 与中转目录结构清晰,便于按模块对照学习。该资源在站内已有 4592 人浏览学习,具备实际参考价值。通过这套实例,开发者可重点掌握支付流程中前后端交互细节、签名生成步骤、证书配置方法和回调验签逻辑,同时获得可直接落地的代码骨架,减少对接新接口时的重复踩坑。 做了这么多年PHP开发,微信支付算是绕不开的接口之一。尤其是微信支付全面转向APIv3以后,老一套v2的MD5签名、退款双向证书那套写法基本都废了。最近我在一个电商项目里完整走了一遍PHP微信支付v3的对接流程,从商户证书申请、APIv3签名,到预下单、回调解密、退款,前前后后踩了不少坑。这篇就基于这个完整实例,把关键流程和代码逻辑整理出来,适合正在接支付模块、需要快速在PHP项目里落地v3的同学参考。
先说一个最大的感受:v3比v2更规范,也更“现代化”,但门槛主要在证书体系和签名逻辑上。如果你之前只写过v2,直接转v3会有点懵;如果是从零开始,反而建议直接学v3,省得后面迁移。
1. 微信支付v3核心概念与准备条件
1.1 v3和v2到底差在哪
微信支付APIv3从2019年开始逐步成为主推版本,现在已经基本全面覆盖。v3最核心的变化有三点:请求签名从MD5改成SHA256withRSA、敏感字段改成AES-256-GCM加密、平台证书验签替代了以往的回调参数验签。
v2时代我们习惯用商户号+API密钥去生成MD5签名,简单归简单,但安全性确实弱。v3把“商户身份”和“平台身份”分开:商户通过商户私钥签名,平台通过平台证书验签;平台返回的数据也通过平台私钥签名。两边各自管好自己那半边,安全边界清晰很多。
另一个直观变化是接口风格,v3统一使用RESTful风格,路径都是/v3/...,请求和响应基本都是JSON,错误码也更结构化,不再像v2那样一个<xml><return_code>串搞定一切。
1.2 必须搞清楚的几组证书和密钥
v3最劝退的就是证书和密钥种类多,很多人一开始就把序列号、密钥路径搞混。我建议先把下面这几个概念刻在脑子里:
- 商户API证书:在微信商户平台“账户中心 > API安全”里申请。拿到的是
apiclient_cert.pem和apiclient_key.pem,相当于商户的“身份证”,请求接口时用它的私钥签名。 - 商户证书序列号:商户API证书本身有一个序列号,请求头里的
serial_no填的是这个,不是平台证书的序列号。获取方法是用openssl命令查看。 - APIv3密钥:这是自己设置的32字节对称密钥,用来解密平台回调里的敏感数据。它和APIv2密钥是两套,别搞混。
- 平台证书:微信支付平台自己的证书,用来验签平台返回的报文字段。在商户平台“API安全”里可以下载,也可以调用
GET /v3/certificates接口拉取。v3上线初期这块很折腾,现在商户平台可以直接下载公钥证书。
提示:如果是在生产环境,建议把平台证书也纳入自动更新流程,因为平台证书会定期轮换。后面我会单独聊这个问题。
1.3 PHP环境与依赖准备
接入v3对PHP版本的建议是PHP 7.2以上,我自己用的是PHP 8.1,跑官方SDK没问题。必须装的扩展有curl、openssl、json,基本是PHP标配,宝塔或Docker环境里默认都带。
如果你的项目用的是Composer,可以装官方SDK:
composer require wechatpay/wechatpay不过我个人建议第一次接v3的同学,先别急着用SDK,手动把签名和请求写一遍会理解得更深。后面我会用原生PHP+curl的方式演示,这样你能看清整个流程里每一步在做什么,排查问题也更有底气。
2. APIv3签名原理与请求封装
2.1 请求签名到底怎么签
v3所有接口请求头里都要带一个Authorization,格式固定为:
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="商户号",nonce_str="随机字符串",signature="签名值",timestamp="时间戳",serial_no="商户证书序列号"其中signature是把下面这段字符串拼起来,用商户私钥做SHA256withRSA签名得到的:
请求方法\n 请求URL路径\n 请求时间戳\n 请求随机字符串\n 请求报文body\n注意是URL路径,比如https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi只需要取/v3/pay/transactions/jsapi。请求体如果没有,就填空字符串,但那一行的换行符不能少。
我在项目里封装了一个签名方法:
private function buildAuthHeader(string $method, string $urlPath, string $body): array { $timestamp = time(); $nonce = bin2hex(random_bytes(16)); $message = $method . "\n" . $urlPath . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; $privateKey = openssl_pkey_get_private(file_get_contents($this->merchantPrivateKeyPath)); openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); $auth = sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"', $this->mchId, $nonce, base64_encode($signature), $timestamp, $this->merchantSerialNo ); return [ 'Authorization: ' . $auth, 'Content-Type: application/json', 'Accept: application/json' ]; }代码里最关键的两个易错点:一是URL路径不要带域名和参数;二是请求体必须是原始JSON字符串,不能是数组或者序列化之后变了样的字符串。
2.2 商户证书序列号的获取
这里单独拎出来说,是因为我见过很多人把商户证书序列号填成了平台证书的序列号,结果请求一直报INVALID_REQUEST或签名错误。
获取商户证书序列号,在服务器上用这条命令:
openssl x509 -in apiclient_cert.pem -noout -serial输出类似serial=1234ABCDEF...,把等号后面的十六进制字符串作为serial_no。字符串里的冒号要去掉,字母大小写都行,但最好统一大写。
2.3 官方SDK vs 手动封装
如果你在团队里维护多个项目,手动封装一个轻量级Client其实更灵活。但要注意,支付接口涉及退款、转账、分账等复杂场景,手动封装很容易漏掉细节。官方SDK的优势在于封装好了证书自动更新和平台证书下载逻辑,尤其是平台证书轮换时,SDK能自动处理。
我现在的建议是:本地调试、学习原理就用原生curl手动请求;上生产环境、快速交付就用官方SDK或者成熟的第三方包。下面先基于原生curl演示完整下单流程,能跑通之后再用SDK替换也不难。
3. 完整实例:JSAPI/小程序支付从下单到回调
3.1 预下单接口实战
这里以小程序支付为例,调起支付前必须先调用统一下单接口拿到prepay_id。接口地址是:
POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi请求体长这样:
{ "appid": "你的小程序appid", "mchid": "你的商户号", "description": "测试商品", "out_trade_no": "20250101000001", "notify_url": "https://yourdomain.com/api/wechat/pay/notify", "amount": { "total": 100, "currency": "CNY" }, "payer": { "openid": "用户在小程序里的openid" } }单位注意,total是整数分。比如商品价格是10.50元,这里要传1050。千万别在代码里直接$total = $amount * 100,因为浮点运算容易出精度问题,建议用(int) round($amount * 100)或者把元转成分的函数。
调用代码如下:
$body = json_encode([ 'appid' => $this->appId, 'mchid' => $this->mchId, 'description' => $description, 'out_trade_no' => $outTradeNo, 'notify_url' => $this->notifyUrl, 'amount' => [ 'total' => $total, 'currency' => 'CNY' ], 'payer' => [ 'openid' => $openid ] ]); $url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi'; $headers = $this->buildAuthHeader('POST', '/v3/pay/transactions/jsapi', $body); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $result = json_decode($response, true); // 正常返回里会有 prepay_id返回成功时,$result['prepay_id']就是要用的预支付交易会话标识。如果请求报错,优先检查签名、证书序列号和appid/mchid是否匹配这三个点。
3.2 前端调起支付参数二次签名
拿到prepay_id之后,小程序端不能直接拿它去调wx.requestPayment,还要生成一个paySign。这一步很多人会漏,或者用错了签名串。
后端需要返回给小程序的参数是:
$params = [ 'appId' => $this->appId, 'timeStamp' => (string) time(), 'nonceStr' => bin2hex(random_bytes(16)), 'package' => 'prepay_id=' . $prepayId, 'signType' => 'RSA' ];然后对这个参数做签名,签名串结构和上面请求体签名类似,但换行拼接内容变成了:
appId\n timeStamp\n nonceStr\n package\n注意最后也有个换行符。签名用的还是商户私钥,算法同样是SHA256withRSA。签名代码:
$message = $params['appId'] . "\n" . $params['timeStamp'] . "\n" . $params['nonceStr'] . "\n" . $params['package'] . "\n"; $privateKey = openssl_pkey_get_private(file_get_contents($this->merchantPrivateKeyPath)); openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); $params['paySign'] = base64_encode($signature);最后把这四个字段加上paySign返回给小程序端,小程序端直接:
wx.requestPayment({ timeStamp: that.timeStamp, nonceStr: that.nonceStr, package: that.package, signType: 'RSA', paySign: that.paySign, success: ... })这个二次签名是JSAPI支付最容易报支付签名验证失败的地方。我排查过好几个项目,基本都是签名串里少了换行符,或者把package值写成了没有prepay_id=前缀的原始字符串。
3.3 支付回调验签与解密
用户支付成功后,微信服务器会把结果POST到你下单时传的notify_url。回调处理是整个流程里最严谨的部分,处理顺序是:验签 -> 解密 -> 更新订单。
回调请求头里会带Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial,请求体是加密后的JSON。先用平台证书验签,验签串是:
Wechatpay-Timestamp\n Wechatpay-Nonce\n 请求体原文\n也就是请求头里那两个值加上原始body拼成字符串,再用平台证书公钥验证签名。如果Wechatpay-Serial在你本地平台证书序列号列表里找不到,就要考虑去下载最新平台证书。
验签通过后,对请求体做AES-256-GCM解密。解密需要的key是APIv3密钥,nonce和附加数据都来自回调请求体里的字段。回调体大概长这样:
{ "id": "回调资源ID", "create_time": "2025-01-01T12:00:00+08:00", "resource_type": "encrypt-resource", "event_type": "TRANSACTION.SUCCESS", "summary": "支付成功", "resource": { "original_type": "transaction", "algorithm": "AEAD_AES_256_GCM", "ciphertext": "加密内容", "associated_data": "关联数据", "nonce": "随机串" } }解密代码:
$ciphertext = base64_decode($resource['ciphertext']); $nonce = $resource['nonce']; $associatedData = $resource['associated_data']; $apiV3Key = $this->apiV3Key; $decrypted = openssl_decrypt( $ciphertext, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, '', $associatedData );解密成功后,你会得到交易详情,比如out_trade_no、transaction_id、trade_state、payer等。拿到后先去数据库查这个订单当前状态,如果已经是“已支付”就直接返回成功,避免重复处理。只有未支付的订单才去更新状态、加积分、发货等。
最终必须给微信返回一个固定格式的响应:
{ "code": "SUCCESS", "message": "成功" }如果你处理失败或想等稍后重试,返回非200状态码或这组JSON里的code不是SUCCESS就可以。微信会按策略重试。
3.4 查询订单与退款接口
除了下单和回调,实际项目里最常用的是查单和退款。查单接口:
GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid=商户号这个是GET请求,签名时请求体为空字符串,URL路径里带上订单号即可。
退款接口:
POST /v3/refund/domestic/refunds退款请求体和v2差别很大,需要用商户证书双向认证,但v3不用双向证书了,只靠请求签名。一个典型退款请求体:
{ "out_trade_no": "20250101000001", "out_refund_no": "20250101000001R", "reason": "用户申请退款", "notify_url": "https://yourdomain.com/api/wechat/refund/notify", "amount": { "refund": 100, "total": 100, "currency": "CNY" } }退款结果也有异步回调,处理逻辑和支付回调一致,只是event_type可能是REFUND.SUCCESS。这里最容易踩的坑是退款金额和原订单金额不匹配,接口会直接拒绝。
4. 常见问题与排查技巧实录
4.1 报错“无可用的平台证书”
这个问题在v3里非常高频,尤其是第一次接的时候。微信官方SDK往往能自动下载平台证书,但如果你是自己封装请求,又没有提前在商户平台下载证书,就会出现“无可用的平台证书”或类似提示。
解决办法分两步。第一步,去商户平台下载最新的平台证书公钥,存到服务器,并在回调验签时使用对应证书。第二步,如果要用接口自动更新,可以调用:
GET /v3/certificates这个接口返回的证书内容也是加密的,需要用APIv3密钥解密。建议直接写一个命令行脚本,每天定时拉取并更新本地证书。我当时是在系统里加了一个定时任务,每天凌晨拉一次,避免平台证书轮换导致回调突然验签失败。
4.2 回调验签一直失败
验签失败的原因,我总结下来大多逃不出这三个:平台证书和Wechatpay-Serial对不上、验签串没有使用原始请求体、时区或时间戳偏差过大。
其中第三点特别隐蔽。微信回调的Wechatpay-Timestamp是Unix秒级时间戳,建议判断一下和当前时间差不超过5分钟。如果服务器时间不准,或者用字符串拼接时多加了空格,都会导致验签失败。另外注意验签串的每一行末尾都有\n,不要把它当成可选项。
4.3 订单金额单位混乱
所有v3接口的金额单位都是“分”,尤其是回调解密后的amount.total也是整数分。前端显示需要除以100。有次同事把元直接传给接口,结果用户实际支付0.01元,内部订单却记录成了1元,对账时才发现。
我建议统一写一个金额工具类:
public function yuanToFen($amount): int { return (int) round((float) $amount * 100); } public function fenToYuan($amount): string { return number_format($amount / 100, 2, '.', ''); }所有接口出入口都走这个工具,基本能杜绝单位错误。
4.4 openssl相关报错与私钥格式问题
如果遇到openssl_sign(): supplied key param cannot be coerced into a private key,基本是私钥文件读取失败。常见原因有三个:私钥路径不对、文件权限不够、私钥内容被转义破坏了。
尤其要注意,有些人在.env配置里用\n代替真实换行,然后file_get_contents后直接丢给openssl_pkey_get_private,这会导致解析失败。正确做法是让私钥保持文件形式,放在服务器安全目录,只读权限,然后从文件读取。
还有一点,如果用了宝塔面板,记得把证书文件的目录权限设置成可读,但不要设成777,有安全风险。
4.5 回调重复通知与幂等处理
微信支付回调设计上就是“可能重复通知”,官方建议至少接收两次。所以更新订单状态时一定要做幂等。最简单的方式就是:先查订单状态,只有待支付状态的订单才更新;也可以用数据库唯一约束,比如transaction_id字段唯一,重复插入直接报错,然后捕获这个错误按成功处理。
我一般会再用Redis加个锁,避免并发回调时两条请求同时读到“待支付”状态,然后各自走一遍发货逻辑。
5. 写在最后的实操建议
接微信支付v3这件事,说难不难,但细节确实多。我个人建议第一次接的时候,一定先把整体流程在脑子里过一遍:商户证书管身份,APIv3密钥管解密,平台证书管验签,然后才是下单、回调、查单、退款这些具体动作。
如果你是在已有系统里接,最好把所有支付相关的配置都放在独立配置项里,线上和沙箱环境分开。上线前至少用小额真实支付测一遍完整链路,特别是回调验签、订单状态流转、退款这三块。
最后再分享一个小技巧:调试阶段可以把请求头、请求体、响应体、验签结果全部写到日志里,但注意要对敏感字段脱敏,尤其是Authorization和商户私钥相关的内容不要完整记录。等跑通了再把日志级别调低。支付模块出问题的时候,完整日志是救命稻草,比看官方文档有效率得多。
这套流程我在两个生产项目里验证过,目前运行很稳定。你按这个思路走,遇到问题也能快速定位。
本文还有配套的精品资源,点击获取