ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

PHP微信支付APIv3对接实战:从签名验签到回调退款全流程

2026/9/7 6:32:03 拓冰建站 浏览量
PHP微信支付APIv3对接实战:从签名验签到回调退款全流程 简介面向 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那样一个xmlreturn_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可以装官方SDKcomposer require wechatpay/wechatpay不过我个人建议第一次接v3的同学先别急着用SDK手动把签名和请求写一遍会理解得更深。后面我会用原生PHPcurl的方式演示这样你能看清整个流程里每一步在做什么排查问题也更有底气。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输出类似serial1234ABCDEF...把等号后面的十六进制字符串作为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:0008: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和商户私钥相关的内容不要完整记录。等跑通了再把日志级别调低。支付模块出问题的时候完整日志是救命稻草比看官方文档有效率得多。这套流程我在两个生产项目里验证过目前运行很稳定。你按这个思路走遇到问题也能快速定位。本文还有配套的精品资源点击获取