简介:面向PHP开发者的微信支付与退款功能示例包,适用于电商及在线服务平台需要接入JSAPI支付、处理订单退款等场景。资源采用原生PHP编写,未依赖微信官方SDK,整体仅7KB、共3个PHP文件,涵盖支付调用主入口、核心类封装以及回调通知处理脚本,结构精简便于快速定位关键逻辑。示例代码完整演示了从统一下单获取prepay_id、生成JSAPI支付签名,到前端wx.chooseWXPay拉起支付,以及退款申请、退款状态查询和异步回调解析的闭环流程,并涉及商户号、密钥等敏感信息的处理提醒。目前已有1008人学习下载,对于希望在PHP项目中快速集成微信支付的开发者来说,是一份可直接参考运行的实用代码,能帮助理解接口参数组织、签名规则与回调机制,降低从零对接的试错成本。
1. PHP 微信支付和退款类:把最容易翻车的支付环节接到能跑
把一套微信支付真正接进来,比文档里写的要绕。统一下单只是开始,后面跟着异步回调验签、退款双向证书、金额单位、对账幂等,任何一个环节没处理好,线上就会翻车。这个 PHP 微信支付和退款类,核心是把 v2 接口里最常用的几件事——小程序/公众号下单、回调验签并解析、发起退款、订单查询——收敛成一批可直接调用的方法,依赖基本是 PHP 内置的 openssl 和 curl,商户号配好就能跑。适合自己维护支付模块、不想为两个接口引入整套大包、又想完全掌控参数的 PHP 开发者。下面按我实测过的顺序拆:先讲参数和签名原理,再讲类怎么落地,最后是接业务时那些不写在文档里的坑。
2. 微信支付 v2 的参数与签名:为什么封装比裸调接口稳
2.1 支付链路里 6 个必配参数与一个回调地址
微信支付 v2 有四个高频接口:统一下单、支付结果通知、申请退款、退款查询。它们共用一套参数体系,核心是「商户身份 + 业务单号 + 金额 + 签名」。所以第一步是把商户侧配置收拢。我一般这样记录:
| 参数 | 说明 | 从哪拿 |
|---|---|---|
| appid | 小程序/公众号/开放平台 AppID | 微信公众平台/开放平台 |
| mch_id | 微信支付商户号 | 商户平台首页 |
| key | v2 API 密钥(32 位) | 商户平台 -> 账户中心 -> API安全 -> APIv2密钥 |
| notify_url | 支付成功通知地址,必须公网 HTTPS | 自己后台配置 |
| cert_path | 退款用的 apiclient_cert.pem 与 apiclient_key.pem 所在目录 | 商户平台下载证书 |
| api_url | 统一下单固定地址 | 官方文档常量 |
这里最容易混的是 key。v2 接口算 sign 用的是 APIv2 密钥,不是 v3 的 APIv3 密钥,也不是商户 API 证书。配错了最典型的反应就是所有请求都报「签名错误」,而且换 MD5、换 HMAC-SHA256 都无效,因为身份本身就错了。
除了配置,还要分清两个单号。out_trade_no 是商户侧唯一订单号,由你生成;transaction_id 是微信支付侧的交易号,支付成功后返回。下单时你传 out_trade_no;回调里两者都有;申请退款时可以用 out_trade_no 定位原订单,也可以直接用 transaction_id。很多人在退款查询时把 out_refund_no(退款单号)和 out_trade_no 混用,后面避坑部分会细说。
2.2 签名算法拆解:字典序、拼接、MD5/HMAC-SHA256
微信 v2 的签名算法看文档只有三句话,落地时却有三个细节:只对非空参数签名、排除 sign 本身、密钥只放在拼接串末尾。这是我在项目里实际跑通的实现:
private function buildSign(array $params): string { // 排除空值和 sign 字段,这是微信签名规则的硬性要求 $filtered = []; foreach ($params as $k => $v) { if ($v !== '' && !is_null($v) && $k !== 'sign') { $filtered[$k] = $v; } } // 按参数名 ASCII 字典序升序排列 ksort($filtered); // 拼接成 a=1&b=2 的形式 $str = ''; foreach ($filtered as $k => $v) { $str .= $k . '=' . $v . '&'; } // 密钥只放在字符串末尾 $str .= 'key=' . $this->config['key']; // sign_type 用于 v2 接口,signType 用于 JSAPI 调起支付 // 都没有时使用类配置的默认签名类型,最安全的是跟随统一下单 $signType = $params['sign_type'] ?? $params['signType'] ?? ($this->config['sign_type'] ?? 'MD5'); if ($signType === 'HMAC-SHA256') { return strtoupper(hash_hmac('sha256', $str, $this->config['key'])); } return strtoupper(md5($str)); }这段代码有两个关键点。一是拼接时没有做 urlencode,微信官方明确参数值不需要 URL 编码,直接拼原始值;二是在 HMAC-SHA256 分支里,hash_hmac 的第二参数是 API 密钥,用于生成消息认证码,而 $str 里那一份 key 是摘要的对象,两者各司其职,不能省也不能重复。
实际调用时,签名是放在 XML 里的 sign 字段。微信服务器收到请求后,会取出除 sign 外的所有字段重新算一遍,再和 sign 比较。回调验签也是同样的逻辑:微信把支付结果参数 POST 到你 notify_url,你按同一套规则算一次 sign,相同才算验签通过。
2.3 为什么用类封装而不是每个接口手写一遍
如果项目里只有统一下单一个接口,手写没问题。但支付业务一旦跑起来,至少会用到下单、回调、查单、退款、退款查询、下载账单六个接口。每个接口都重写一遍签名和 XML 解析,就会出现两类典型问题。
第一是签名逻辑多副本。改一处算法,比如从 MD5 换成 HMAC-SHA256,其他文件忘了同步,线上就会出现只有部分接口签名正常的诡异现象。第二是 XML 解析不一致。有人用 simplexml,有人用正则,CDATA 处理方式不同,解析结果就差一个空格,回调验签时对不上。
用一个类把这些收口,核心价值是把变化控制在单个文件里。配置通过构造函数注入,签名、POST 请求、XML 转换都做成私有方法,业务代码只关心下单参数和回调结果。后面就算要迁移到 v3,也只需新增一个适配层。我自己维护支付模块时坚持「支付逻辑不进业务模型」,所有微信请求都在这一个类里,出问题排查起来不用来回翻项目。
另外要泼一盆冷水:如果项目是多商户平台,每个商户有自己的 key 和证书,单个单例类不够,需要按商户维度的配置工厂。这个类的设计假设是单商户。多商户要么改成传入配置的实例工厂,要么在类里维护「商户号 => 配置」映射。
2.4 一次统一下单的参数与返回字段对应关系
下单前至少要准备这么一组参数:
$orderParams = [ 'appid' => $config['appid'], 'mch_id' => $config['mch_id'], 'device_info' => 'WEB', 'nonce_str' => $pay->nonce(), 'body' => 'PHP 微信支付测试单', 'out_trade_no' => '20260101120000123', 'total_fee' => 1, // 单位分,1 表示 0.01 元 'spbill_create_ip' => '8.8.8.8', 'notify_url' => $config['notify_url'], 'trade_type' => 'JSAPI', 'openid' => 'oUpF8uMuAJOQI2S58DF6uQ', ];这里 total_fee 单位分,是 v2 里最容易搞错的字段。1 元就是 100,不是 1,也不是 0.01。下单成功后返回的 prepay_id 有效期为两小时,前端 JSAPI 调起支付时,还需要做一次二次签名。这个对应关系搞清楚,下一章写类方法时就能直接对上号。
3. 把三个核心方法写成 PHP 类:下单、回调验签、退款
3.1 类骨架与通用工具方法
支付类只做一件事:把微信 v2 的 HTTP + XML + 签名协议封装成 PHP 方法。构造参数只收配置,不直接依赖框架。先看骨架:
class WxPayV2 { private $config = []; public function __construct(array $config) { $this->config = array_merge([ 'appid' => '', 'mch_id' => '', 'key' => '', 'notify_url' => '', 'cert_path' => '', 'sign_type' => 'MD5', // 统一下单、退款、回调验签统一使用 ], $config); } private function nonce(): string { return md5(uniqid(mt_rand(), true)); } }nonce_str 建议用 32 位以内的随机串,微信要求最长 32 位。下面三个工具方法决定这个类在其他框架里能不能直接跑:XML 转数组、数组转 XML、curl 发送请求。
private function toXml(array $data): string { $xml = '<xml>'; foreach ($data as $k => $v) { $xml .= '<' . $k . '><![CDATA[' . $v . ']]></' . $k . '>'; } return $xml . '</xml>'; } private function fromXml(string $xml): array { $obj = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA); return json_decode(json_encode($obj), true) ?: []; } private function postXml(string $url, string $xml, string $sslCert = '', string $sslKey = ''): string { $ch = curl_init($url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $xml); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: text/xml']); curl_setopt($ch, CURLOPT_TIMEOUT, 30); if ($sslCert && $sslKey) { // 申请退款必须使用双向证书,下单和查询不需要 curl_setopt($ch, CURLOPT_SSLCERT, $sslCert); curl_setopt($ch, CURLOPT_SSLKEY, $sslKey); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); } $resp = curl_exec($ch); if (curl_errno($ch)) { throw new RuntimeException('curl 请求失败:' . curl_error($ch)); } curl_close($ch); return $resp; }fromXml 的 LIBXML_NOCDATA 必须写,否则 CDATA 里的中文和特殊符号会解析出问题。postXml 支持可选证书参数,这样退款接口和普通接口可以用同一套发送逻辑。
3.2 统一下单与 JSAPI 调起支付
统一下单方法接收业务订单数据,补上 appid、mch_id、nonce_str、通知地址,生成签名后 POST 到微信。成功后返回包含 prepay_id 的数组。
public function unifiedOrder(array $order): array { $params = [ 'appid' => $this->config['appid'], 'mch_id' => $this->config['mch_id'], 'nonce_str' => $this->nonce(), 'body' => $order['body'], 'out_trade_no' => $order['out_trade_no'], 'total_fee' => $order['total_fee'], 'spbill_create_ip' => $order['ip'], 'notify_url' => $this->config['notify_url'], 'trade_type' => $order['trade_type'] ?? 'JSAPI', ]; if ($params['trade_type'] === 'JSAPI') { // 小程序或公众号支付必须传 openid $params['openid'] = $order['openid']; } // 签名类型保持一致,默认 MD5 $params['sign_type'] = $this->config['sign_type'] ?? 'MD5'; $params['sign'] = $this->buildSign($params); $resp = $this->postXml( 'https://api.mch.weixin.qq.com/pay/unifiedorder', $this->toXml($params) ); $data = $this->fromXml($resp); if (($data['return_code'] ?? 'FAIL') !== 'SUCCESS') { throw new RuntimeException('通信失败:' . ($data['return_msg'] ?? '')); } if (($data['result_code'] ?? 'FAIL') !== 'SUCCESS') { throw new RuntimeException('业务失败:' . ($data['err_code_des'] ?? $data['err_code'])); } return $data; }注意 total_fee 必须传整数分,很多接口报「金额格式错误」就是因为传了小数。trade_type 为 JSAPI 时必须传 openid;NATIVE 则不需要 openid,而是返回 code_url 给用户扫码。成功后 data 里有 prepay_id,但前端调起支付不能直接拿它,需要按 JSSDK 要求重组参数并再一次签名:
public function jsapiParams(array $unifiedResp): array { $signType = $this->config['sign_type'] ?? 'MD5'; $params = [ 'appId' => $this->config['appid'], 'timeStamp' => (string) time(), 'nonceStr' => $this->nonce(), 'package' => 'prepay_id=' . $unifiedResp['prepay_id'], 'signType' => $signType, ]; $params['paySign'] = $this->buildSign($params); return $params; }jsapiParams 里的签名类型必须与统一下单一致。timeStamp 是字符串,不要转成 int 给前端,否则某些版本的 wx.requestPayment 会因类型不对报错。buildSign 会处理 signType 这个字段,所以直接传整个 params 就能算出对接微信要求的 paySign。
3.3 回调验签与结果解析
支付成功后微信服务器会异步 POST 一个 XML 到 notify_url。很多项目在这里翻车:只判断 return_code 就直接改订单状态,完全没验签。正确顺序是先验签,再判断业务结果,然后更新订单,最后返回成功应答。
public function handleNotify(string $xml): array { $data = $this->fromXml($xml); if (($data['return_code'] ?? 'FAIL') !== 'SUCCESS') { throw new RuntimeException('回调通信失败'); } if (($data['result_code'] ?? 'FAIL') !== 'SUCCESS') { throw new RuntimeException('回调业务失败'); } if (($data['sign'] ?? '') !== $this->buildSign($data)) { throw new RuntimeException('回调验签失败'); } return $data; }这里验签用到的 buildSign 与下单时完全相同。因为 fromXml 已经把 CDATA 去除,sign 字段本身也在返回数组里,而 buildSign 内部会排除 sign 字段,所以可以直接把整个 $data 传进去。签名类型由构造时设置的 sign_type 决定,和统一下单保持一致才不会验签失败。
微信要求收到通知后返回固定格式的 XML 表示成功。如果不返回,微信会在 24 小时内按策略重试。我一般把应答方法也放在类里:
public function replyOk(): string { return '<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>'; } public function replyFail(): string { return '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[处理失败]]></return_msg></xml>'; }return_code 为 SUCCESS 只是告诉微信「我收到了」,并不代表订单业务处理成功。不要在业务抛异常时还是返回 SUCCESS,微信会认为处理成功就不重试了。
3.4 发起退款与退款查询
退款是这里唯一必须使用双向证书的接口。证书不是签名的替代品,而是传输层的身份认证。发起退款时,请求要求带上商户证书,微信服务器的证书验证失败会直接返回 SSL 错误。
public function refund(array $refund): array { $params = [ 'appid' => $this->config['appid'], 'mch_id' => $this->config['mch_id'], 'nonce_str' => $this->nonce(), 'out_trade_no' => $refund['out_trade_no'], 'out_refund_no' => $refund['out_refund_no'], 'total_fee' => $refund['total_fee'], 'refund_fee' => $refund['refund_fee'], ]; $params['sign_type'] = $this->config['sign_type'] ?? 'MD5'; $params['sign'] = $this->buildSign($params); $certDir = $this->config['cert_path']; $resp = $this->postXml( 'https://api.mch.weixin.qq.com/secapi/pay/refund', $this->toXml($params), $certDir . '/apiclient_cert.pem', $certDir . '/apiclient_key.pem' ); $data = $this->fromXml($resp); if (($data['return_code'] ?? 'FAIL') !== 'SUCCESS') { throw new RuntimeException('退款通信失败:' . ($data['return_msg'] ?? '')); } if (($data['result_code'] ?? 'FAIL') !== 'SUCCESS') { throw new RuntimeException('退款失败:' . ($data['err_code_des'] ?? $data['err_code'])); } return $data; }申请退款成功后返回的是「受理成功」,不代表最终退款到账。要看最终结果,得靠退款结果通知或主动调用退款查询接口。退款查询参数与退款几乎一样,区别是不需要证书,按 out_refund_no 或 transaction_id 查询:
public function refundQuery(string $outRefundNo): array { $params = [ 'appid' => $this->config['appid'], 'mch_id' => $this->config['mch_id'], 'nonce_str' => $this->nonce(), 'out_refund_no' => $outRefundNo, ]; $params['sign_type'] = $this->config['sign_type'] ?? 'MD5'; $params['sign'] = $this->buildSign($params); $resp = $this->postXml( 'https://api.mch.weixin.qq.com/pay/refundquery', $this->toXml($params) ); $data = $this->fromXml($resp); if (($data['return_code'] ?? 'FAIL') !== 'SUCCESS') { throw new RuntimeException('退款查询失败'); } return $data; }refund_fee 是本次退款金额,total_fee 是订单原始总金额,不是「剩余可退金额」。部分退款时,total_fee 仍然传原订单总金额,累计退款不能超过它。这个点极其容易翻车,下一章详细说。
4. 避坑记录:签名错误、回调丢失、退款超退,5 个我踩过的坑
4.1 签名与参数:signature 错误、密钥混用、金额单位
坑 1:接口一直报「签名错误」,小程序端提示「用户态签名 signature 错误」。
现象:统一下单或者退款请求返回 return_code=FAIL,return_msg 为签名错误;小程序拉起支付时也弹「用户态签名 signature 错误」。
原因:这个坑我排查过很多次,九成是以下三种之一。第一,参数名大小写错,比如把 mch_id 写成 mchId,签名算法里字典序就变了;第二,AppID 和商户号不是同一主体,微信会按这两个参数去找商户配置,对不上就认为签名无效;第三,用了 APIv3 密钥去算 v2 的 sign,两者在商户平台是不同的密钥。
解决:先打印发送前的完整 XML,确认 sign 之外的所有参数名与文档一致;再核对 appid 和 mch_id 是否匹配;最后进商户平台 API 安全里重新设置 APIv2 密钥。改完密钥要等 5 分钟后重试,微信端配置有缓存。如果是 JSAPI 调起支付环节报 signature 错误,还要检查二次签名的参数里 package 是不是少了 prepay_id= 前缀。
坑 2:total_fee 传了 1,用户付了 1 分钱而不是 1 元。
现象:订单金额全错,用户付 0.01 元但后台记录 1 元;或者反过来。
原因:微信 v2 金额单位是分,total_fee=1 代表 0.01 元;很多从其他支付渠道迁移上来的代码习惯传「元」,忘了转换。
解决:入库和传参统一用分,所有金额计算用 int 类型。前端展示时再除以 100。项目中所有支付金额相关字段都不要用 float,累计退款计算用字符串或 BigDecimal。
坑 3:回调验签一直失败,但同一套签名代码下单却没问题。
现象:下单接口签名正常,微信回调到 notify_url,handleNotify 里验签抛出异常。
原因:常见的有两个。一是从 $_POST 或者框架解析后的数据拿回调体,丢了原始报文;二是 simplexml 没加 LIBXML_NOCDATA,CDATA 里的内容解析后把前后空格也算进去了。
解决:不要用 $_POST 或框架解析后的数据,直接用 php://input 读取原始请求体;fromXml 必须加 LIBXML_NOCDATA。如果还是失败,把接收到的 XML 原样存到日志,分析里面有没有多余换行和空格。另外要确认回调验签的 sign_type 和统一下单时一致,否则必然验签失败。
4.2 回调与退款:XML 解析、证书路径、单号混用
坑 4:申请退款报「金额超限」或「订单已退款」。
现象:同一个订单第二次申请部分退款时,微信返回 SYSTEMERROR 或金额超限;或者退款金额明明小于订单金额却被判定超退。
原因:total_fee 传的是剩余可退金额,而不是订单原始总额。微信校验规则是「该订单已成功退款金额 + 本次退款金额 <= 订单原始 total_fee」。部分退款时,原始 total_fee 不能变,改了就会超过限制。
解决:申请退款时 total_fee 永远取订单表的原始总金额,refund_fee 取本次退款金额。业务侧在退款前查询订单,累计已退金额加本次金额大于原始金额就拦截,不等微信报错。
坑 5:退款接口直接 SSL 报错,curl error 35 或证书读取失败。
现象:发起退款时 curl 返回错误 35(SSL connect error),或者提示 apiclient_cert.pem 无法读取。
原因:证书路径不对、证书权限不够,或者把 apiclient_key.pem 误当 apiclient_cert.pem 传了。还有一种情况是证书目录存的是商户平台下载的 .p12 文件,没有用 openssl 转成 PEM 格式。
解决:确认下载包里解压出来的 apiclient_cert.pem 和 apiclient_key.pem 存在且可读;路径用绝对路径或者以框架 root 目录为基准的路径;如果只有 apiclient.p12,用openssl pkcs12 -in apiclient.p12 -clcerts -nokeys -out apiclient_cert.pem和openssl pkcs12 -in apiclient.p12 -nocerts -nodes -out apiclient_key.pem转换。密钥文件权限设为 600。
5. 把支付退款类接进业务:订单表、回调幂等与每日对账
5.1 订单表和退款单表怎么设计
支付类的职责只到「接口返回成功」为止,业务侧真正要扛住的是订单状态。我习惯用两张表:支付订单表和退款单表。
CREATE TABLE `pay_order` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `out_trade_no` varchar(32) NOT NULL COMMENT '商户订单号', `transaction_id` varchar(32) NOT NULL DEFAULT '' COMMENT '微信支付交易号', `total_fee` int NOT NULL COMMENT '订单总金额,单位分', `refund_fee` int NOT NULL DEFAULT 0 COMMENT '累计已退款金额,单位分', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已关闭 3已全额退款', `notify_raw` text COMMENT '最后一次回调原始报文', `pay_time` datetime DEFAULT NULL COMMENT '支付成功时间', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_out_trade_no` (`out_trade_no`), KEY `idx_transaction_id` (`transaction_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';out_trade_no 唯一约束是防单号重复的最后一道防线。transaction_id 建立索引,对账时按微信订单号查本地记录用得上。notify_raw 存最后一次回调原文,排查问题时能看到微信到底传了什么。
退款单表记录每次退款请求,一张表会多次插入记录,一个订单可以存在多条部分退款单:
CREATE TABLE `pay_refund` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `out_refund_no` varchar(32) NOT NULL COMMENT '商户退款单号', `out_trade_no` varchar(32) NOT NULL COMMENT '关联的商户订单号', `refund_fee` int NOT NULL COMMENT '本次退款金额,单位分', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败', `refund_id` varchar(32) NOT NULL DEFAULT '' COMMENT '微信退款单号', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_out_refund_no` (`out_refund_no`), KEY `idx_out_trade_no` (`out_trade_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退款单表';设计上有意识地区分订单状态和退款状态。订单状态里 3 表示「全额退款」,部分退款时订单仍是 1(已支付),靠 refund_fee 累计值判断还能不能退。
5.2 回调更新订单:幂等与状态机
回调处理方法里最容易犯的错是收到一次通知就更新一次订单,不判断当前状态,导致重复入账、发货多次。我的做法是先读订单,再校验金额,再判状态,最后落库并以事务提交。
public function onNotify(string $xml): string { $data = $this->pay->handleNotify($xml); $order = OrderModel::where('out_trade_no', $data['out_trade_no'])->first(); if (!$order || $order->total_fee != $data['total_fee']) { // 单号不存在或金额不一致,记日志后告知微信失败 return $this->pay->replyFail(); } if ($order->status === 1 && $order->transaction_id === $data['transaction_id']) { // 已处理过,直接返回成功,避免微信重试时重复处理 return $this->pay->replyOk(); } $order->transaction_id = $data['transaction_id']; $order->status = 1; $order->pay_time = date('Y-m-d H:i:s', strtotime($data['time_end'])); $order->notify_raw = $xml; $order->save(); // 到这里才扣库存、加余额、发通知 dispatch(new OrderPaidEvent($order->out_trade_no)); return $this->pay->replyOk(); }这段逻辑有三个关键点。第一,先校验金额和单号,防止别人伪造回调;第二,用「订单状态 + transaction_id 相同」判断幂等,而不是只查一次状态;第三,业务事件放在 save 之后,且与订单更新在同一事务里处理,避免订单已改但库存没扣的情况。
订单状态流转我固定为 0 → 1 → 2/3。0 是待支付,支付成功后是 1;如果用户主动取消或超时未付,关闭为 2;退款累计达到 total_fee 时置为 3。状态只允许向后流转,不允许从 1 回到 0。在更新语句上可以加where('status', 0)条件防止并发覆盖。
5.3 每日对账:从微信账单到本地订单
支付跑起来以后,最怕「用户说付了,系统说没付」。靠回调不保险,因为回调可能丢失,所以要拉微信账单做每日对账。微信 v2 的下载对账单接口返回的是文本,不是 XML:
curl "https://api.mch.weixin.qq.com/pay/downloadbill?appid=APPID&mch_id=MCH_ID&nonce_str=NONCE&bill_date=2026-01-01&bill_type=ALL&sign=SIGN" -o /tmp/wx_bill.txt签名字段仍然用第二章的 buildSign 生成。下载后账单是一个类似 CSV 的文件,前几行是标题,最后一行是汇总。我一般在 PHP 脚本里逐行解析,按 out_trade_no 和 transaction_id 与本地 pay_order 比对:
$localCount = OrderModel::whereBetween('pay_time', [ $start, $end ]) ->where('status', 1)->count(); $wxCount = parseBill($billFile); // 统计账单中的成功交易笔数 if ($localCount !== $wxCount) { // 差异订单写入对账异常表,人工介入 }严格做法是按交易号维度比对,统计笔数只能查出总数不一致。把账单里的 transaction_id 集合取出来,与库里当天 transaction_id 集合做差集,两边多出来的都记录;账单多而库少说明回调丢失,需要调用订单查询接口补单;库多而账单少说明数据有问题,需要人工核。
6. 从 v2 到 v3 的迁移思路:用平台证书验证回调真实性
微信支付现在新商户默认用 v3 接口。v3 与 v2 最大的区别不只是 REST API + JSON,而是签名方向反过来了:v2 里你用 APIv2 密钥给请求算签名,微信返回的验签逻辑在同一套规则里;v3 里你的请求用商户 API 证书私钥签名,而回调的验签要反过来用微信支付平台证书的公钥验。这意味着不能沿用buildSign($data)那套。
先看 v3 回调验签的骨架:
function verifyV3Notify(string $body, string $timestamp, string $nonce, string $serial, string $signature): bool { // 1. 用 serial 找到对应的微信支付平台证书 // 2. 拼接验签串:timestamp + "\n" + nonce + "\n" + body + "\n" $message = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; // 3. 用平台证书公钥验证 RSA-SHA256 签名 $publicKey = openssl_pkey_get_public($platformCert); return openssl_verify($message, base64_decode($signature), $publicKey, OPENSSL_ALGO_SHA256) === 1; }这里关键点是验签串里的 body 必须是原始请求体,不能是框架解析后的数组。验签成功后,回调里的 resource 字段还是加密的 JSON,需要用 APIv3 密钥做 AES-256-GCM 解密,拿到里面的 out_trade_no 和 transaction_id。解密完成后,再走一遍第 5 章的幂等更新逻辑。
从 v2 类迁移到 v3,我并不建议直接把类内部推翻重写,而是保留统一的下单/退款封装接口,新增一个 WxPayV3 适配类。业务侧只改实例化那一行,订单表和回调逻辑全部复用。判断新项目用什么版本,最简单的标准是看商户平台是否能看到 APIv3 密钥设置入口,能看到就以 v3 为准,看不到就先用 v2。
这套 WxPayV2 类代码、证书配置示例和两个示例脚本都在资源包里,下载后按第二章的配置表填好参数,直接跑一遍 examples 下的下单脚本就能看到完整链路。我自己吃过亏:早期做回调时直接信任推送数据,不禁验签,结果被第三方拿伪造通知把订单状态刷成已支付,虽然金额对不上没造成实际资损,但排查了整整一天。从那以后,我每次上线支付相关变更,都会强制走一遍「下单 → 模拟回调 → 验签 → 查单确认 → 退款 → 退款查单」六个步骤,并且把每一段的日志单独留一份。多商户项目还会配一份「证书与密钥更换登记表」,谁在什么时间换过 key,记录得清清楚楚。希望帮到你。
本文还有配套的精品资源,点击获取