简介:面向PHP开发者的微信支付与退款功能实现包,聚焦电商及在线服务场景下的JSAPI支付流程与订单退款操作。资源以3个PHP文件完整演示了从统一下单获取prepay_id、生成JSAPI支付签名、前端wx.chooseWXPay调用,到提交退款申请、查询退款状态及处理异步回调通知的完整链路。代码不依赖微信官方SDK,对现有项目集成友好,适合具备基础PHP知识、希望快速接入支付能力的开发者参考。压缩包整体仅7KB,包含三个PHP文件(支付主逻辑、支付核心类与回调通知处理),结构精简可快速定位关键方法。该资源已有1008人学习浏览,示例中包含参数构造、签名算法及XML回调解析等关键实现,可直接移植并根据自身业务调整。
1. 一个 PHP 类把微信支付和退款收口:下单、回调、退款该由谁来做
先回答最直接的问题:标题里的“PHP微信支付和退款类”,指的是用 PHP 封装微信支付接口的通用类,把统一下单、支付回调验签、退款申请这三个高频动作收敛到一起,让业务代码不再跟签名算法、XML 报文、商户证书打交道。我在某公司的商城项目里见过最典型的情况:支付逻辑散落在控制器和模型里,下单处复制一份,退款处再复制一份,签名改了四处没同步,最后对账对不上,只能人肉翻日志。
这个类存在的意义是:支付是改动频率低但风险极高的模块,应该被当成独立组件对待。它适合两类人:一类是刚接手老 PHP 项目的开发者,需要把散落的支付代码整理成可维护的单文件类;另一类是给新项目做支付模块选型的人,想先看清 V2 接口要处理哪些事,再决定要不要直接上签名和报文都不同的新版接口。
这篇实战笔记会直接给出一条可复现的 V2 实现路径,从签名、下单、回调到退款和证书,每一步都有代码与参数说明,最后一章再讲我踩过的坑,以及怎么验证这个类没有写歪。
2. 下单封装:签名、参数与拉起收银台的完整通道
微信支付接口里,下单是整个链条的入口。老项目里最常见的接口版本是 API v2,理由很简单:配置只需要一个 API 密钥,上下行都是 XML,退款时用商户证书做双向认证,整套逻辑用一个类就能装下。新版接口改成 JSON 报文、APIv3 密钥加证书序列号签名,安全模型更复杂,但存量项目迁过去成本不低。所以这里我按 v2 讲实现,后面会专门说新旧版本的关键差异,避免你抄完代码接不住新接口。
2.1 类骨架先立:配置注入与 MD5 签名工具
先把类的骨架写出来。构造方法接收一个配置数组,把 appid、商户号、API 密钥、回调地址都存成私有属性,然后封装签名方法。签名是 v2 接口的命根子,下单、回调、退款、查询,每一次请求的签名规则都一样,所以签名方法一定要独立、复用、没有副作用。
class WechatPayV2 { private $appid; private $mchId; private $apiKey; private $notifyUrl; public function __construct(array $config) { $this->appid = $config['appid']; $this->mchId = $config['mch_id']; $this->apiKey = $config['api_key']; $this->notifyUrl = $config['notify_url']; } /** * 生成签名:参数名ASCII升序,key=value&key=value...&key=API密钥, * 取MD5后转大写。最终结果就是sign字段的值。 */ public function buildSign(array $params): string { // 只保留非空且不等于sign的字段参与签名 $filtered = []; foreach ($params as $key => $value) { if ($key === 'sign' || $value === '' || $value === null) { continue; } $filtered[$key] = $value; } ksort($filtered, SORT_STRING); $stringA = ''; foreach ($filtered as $key => $value) { $stringA .= $key . '=' . $value . '&'; } $stringA .= 'key=' . $this->apiKey; return strtoupper(md5($stringA)); } public function randomStr(int $length = 32): string { return bin2hex(random_bytes($length / 2)); } }逻辑说明:先过滤掉空字符串和 sign 本身,再按键名做 ASCII 升序排序,拼成key=value&key=value的形式,最后在末尾加上key=商户API密钥,整体做 MD5 并转大写。注意末尾的密钥拼接,不是把 key 和 value 对调,也不是在开头加。
这套签名里两个最容易被忽略的参数:一是nonce_str随机字符串,每次请求必须不同,否则微信会认为你在重放请求;二是参与签名的字段必须和最终发送的报文完全一致,多传一个自定义字段、少传一个空字段,都会造成签名对不上。如果商户后台配置的签名方式是 HMAC-SHA256,那么只需要把md5换成hash_hmac('sha256', $stringA, $this->apiKey),再转大写,其它逻辑不变。
2.2 统一下单:最常用的 NATIVE 与 JSAPI 分支
统一下单接口把订单信息提交给微信,微信返回预支付会话标识prepay_id。NATIVE 支付返回的是code_url,前端用这个地址生成二维码;JSAPI 支付需要拿到用户的 openid,返回结果中带prepay_id,前端再拿它调起收银台。两个分支的下单参数几乎一样,区别只在trade_type和个别必填项。
public function unifiedOrder(array $order): array { $params = [ 'appid' => $this->appid, 'mch_id' => $this->mchId, 'nonce_str' => $this->randomStr(), 'body' => $order['body'], 'out_trade_no' => $order['out_trade_no'], 'total_fee' => $order['total_fee'], 'spbill_create_ip' => $order['client_ip'], 'notify_url' => $this->notifyUrl, 'trade_type' => $order['trade_type'], // NATIVE / JSAPI / MWEB ]; if ($order['trade_type'] === 'JSAPI') { $params['openid'] = $order['openid']; } if ($order['trade_type'] === 'NATIVE') { // NATIVE 会用到 product_id,便于支付完成后跳转 $params['product_id'] = $order['product_id'] ?? ''; } $params['sign'] = $this->buildSign($params); $xml = $this->toXml($params); $responseXml = $this->postXml('https://api.mch.weixin.qq.com/pay/unifiedorder', $xml); return $this->fromXml($responseXml); }逻辑说明:下单前业务侧已经生成订单号out_trade_no,金额也换算成以“分”为单位的整数——这是 v2 接口的硬性要求,59.9 元传5990,传浮点数基本都会被微信拒掉。spbill_create_ip填用户真实 IP,别填服务器 IP,NATIVE 支付对这个字段校验相对宽松,JSAPI 校验严格一些。
参数说明里最值得关注的是out_trade_no:它在商户号下必须唯一,一旦提交过就不能再重复使用。很多项目在这里踩坑,订单号用date('YmdHis')加随机数拼接,并发高了还是可能重复。我一般会用订单来源前缀 + 用户ID + 时间戳 + 四位随机数拼成 32 位以内的字符串。
toXml和postXml属于类里的基础工具方法,前者把参数数组拼成 CDATA 包着的 XML,后者用 curl 提交数据并接收返回。curl 这里有个细节:v2 统一下单只需要常规 HTTPS 请求,不需要带证书;到了退款接口,才需要载入商户证书。
2.3 下单返回的 code_url 与收银台落地
NATIVE 支付到这一步,接口返回的报文里有code_url,它是一个weixin://wxpay/...开头的链接,前端把链接渲染成二维码即可。JSAPI 则要拿prepay_id做二次签名,生成前端调起收银台所需的参数,这一步很多新手会漏。
public function jsapiPayParams(string $prepayId): array { $params = [ 'appId' => $this->appid, 'timeStamp' => (string) time(), 'nonceStr' => $this->randomStr(), 'package' => 'prepay_id=' . $prepayId, 'signType' => 'MD5', ]; $params['paySign'] = $this->buildSign($params); return $params; }逻辑说明:这个数组不是直接发给微信的请求参数,是返回给前端浏览器、供WeixinJSBridge或微信内置浏览器调起支付用的。注意timeStamp是字符串类型,package的值必须是prepay_id=加预支付ID,不能只传 ID。paySign的生成方式仍然是同样的签名算法,因为数组里字段名与下单时的字段名不同,所以签名结果也不同,这不算异常。
收银台落地时的顺序建议是:先调unifiedOrder,拿到code_url或prepay_id后立刻把订单状态写入“待支付”,再返回给前端。不要在拿不到返回值时把订单标记成失败,因为用户可能根本没走到扫码那一步。
3. 回调处理:验签、金额比对与幂等的完整闭环
支付回调是最容易被当成“支付成功通知”处理的部分,实际上它是微信服务器告诉你“有一笔订单有支付结果”的报文。这个报文可能是最终成功,也可能是失败或者重复通知。把回调当黑匣子的人,最后都会在超卖和漏单之间二选一。
3.1 回调报文长什么样:先抓一段再写代码
收到回调后第一步永远是验签,不是更新订单状态。v2 回调报文是一段 XML,核心字段包括return_code、result_code、out_trade_no、transaction_id、total_fee、sign。
<xml> <appid><![CDATA[wx8888888888888888]]></appid> <mch_id><![CDATA[1588888888]]></mch_id> <nonce_str><![CDATA[5K8264ILTKCH16CQ2502SI8ZNMTM67VS]]></nonce_str> <out_trade_no><![CDATA[ORDER20240601001]]></out_trade_no> <transaction_id><![CDATA[4200001234202406011234567890]]></transaction_id> <total_fee>5990</total_fee> <result_code><![CDATA[SUCCESS]]></result_code> <return_code><![CDATA[SUCCESS]]></return_code> <sign><![CDATA[ABCDEF0123456789ABCDEF0123456789]]></sign> </xml>回调的notify_url必须在公网可访问,且不能有鉴权跳转。微信在未收到确认应答时会按间隔重试:15 秒、15 秒、30 秒、3 分钟、10 分钟、20 分钟、30 分钟、30 分钟、30 分钟、60 分钟,最长持续 24 小时。这个机制决定了你的回调入口必须做到两件事:快速响应,以及重复通知不产生副作用。
3.2 验签逻辑与 SUCCESS 判定顺序
验签时要用微信发送过来的原始参数(去掉 sign 字段)重新计算签名。注意不能用自己数据库里的数据,因为你要验证的是“这条通知确实是微信发的”。下面是回调处理的标准流程。
public function handleNotify(string $xml): string { $data = $this->fromXml($xml); // 1. 验签:计算出的签名必须与报文里的sign一致 if ($this->buildSign($data) !== $data['sign']) { $this->log('notify sign mismatch', $data); return '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[签名错误]]></return_msg></xml>'; } // 2. 检查协议层与业务层结果 if ($data['return_code'] !== 'SUCCESS' || $data['result_code'] !== 'SUCCESS') { return '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[支付未成功]]></return_msg></xml>'; } // 3. 校验订单存在且金额一致 $order = $this->findOrderByOutTradeNo($data['out_trade_no']); if (!$order) { $this->log('order not found', $data); return '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[订单不存在]]></return_msg></xml>'; } if ((int) $order['total_fee'] !== (int) $data['total_fee']) { $this->log('amount mismatch', [ 'expect' => $order['total_fee'], 'actual' => $data['total_fee'], ]); return '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[金额不一致]]></return_msg></xml>'; } // 4. 幂等:订单已支付则直接返回SUCCESS,不做重复处理 if ($order['status'] === 'paid') { return $this->successReply(); } // 5. 落库更新订单状态,这里只登记支付结果,不触发发货 $this->markOrderPaid($order['id'], $data['transaction_id']); return $this->successReply(); }逻辑说明:先验签再查订单,是防止有人伪造回调报文;查订单再比金额,是防止金额被替换或订单号撞车。return_code表示协议层是否成功,result_code表示业务层是否成功,两个都必须是SUCCESS。步骤 4 的幂等判断是必须的:微信重试回调时,如果重复更新订单状态,就会造成重复发货、重复发卡。
successReply()返回的是固定 XML:
<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>微信收到这个应答才算通知完成。业务处理失败时不要硬回SUCCESS,应该回FAIL,让微信稍后重试。
3.3 为什么必须回 SUCCESS,以及回调死循环的真相
很多项目把“更新订单状态”写在回调里,然后顺手把“发货”也写在回调里,结果某次下单时库存扣减异常,代码抛了 RuntimeException,订单状态改了但回调返回了 FAIL。微信开始一遍遍重试,每次都重新扣库存,于是超卖。
我的做法是回调里只做“预确认”:验签通过、金额一致后,先把订单状态更新为paid,返回SUCCESS,然后在队列里异步处理发货或者发卡。回调的职责是告诉微信“我收到了”,不是把整个交易流程跑完。这样即使异步任务失败,也不会触发微信重试,业务侧可以用自己的定时任务或人工介入兜底。
之前遇到过一次线上问题:回调入口被网关上的防重放插件拦截,返回了 403,微信拿不到SUCCESS就一直重试,日志里全是同一笔订单的通知。排查时先看网关访问日志,再查业务日志,最后发现是安全插件把同一个nonce_str的重复请求拦了。解决方式是把微信回调 IP 段加入白名单,同时在业务层做幂等,让重复请求进来也能安全返回。
4. 退款类:证书双向认证、异步结果与主动查询兜底
退款接口比下单敏感得多,因为它是资金反向流动。v2 的退款接口走的是secapi域名,要求用商户证书做双向 TLS 认证。很多人在同一个类里复用下单的 curl 方法,结果退款永远报“证书验证失败”,原因就是下单和退款走的根本不是同一套鉴权链路。
4.1 退款为什么不能复用下单通道
统一下单是普通的 HTTPS 请求,服务器验证微信的证书,不需要你出示客户端证书。退款接口不同,微信要求商户请求时出示apiclient_cert.pem和apiclient_key.pem,微信端再验证这个证书对应的商户号。这就是双向认证:不仅是 HTTPS 加密,还验明你的身份。
证书文件从微信商户平台下载后,解压出来有两个文件:证书文件apiclient_cert.pem和密钥文件apiclient_key.pem。部署时放在 Web 根目录之外,比如项目下的storage/certs/,权限设置为 600。千万别放 public 目录,证书泄漏等于任何人都能发起退款。
退款接口必填参数里,transaction_id和out_trade_no至少要传一个,out_refund_no是商户自己的退款单号,必须唯一。total_fee是原订单总金额,refund_fee是本次退款金额,两者单位都是分。部分退款时refund_fee小于total_fee,全额退款时两者相等。
4.2 退款请求的发起与证书设置
退款的方法和统一下单结构类似,但 curl 部分必须加载证书文件。这里给出一个完整可跑的退款方法。
public function refund(array $refundData): array { $params = [ 'appid' => $this->appid, 'mch_id' => $this->mchId, 'nonce_str' => $this->randomStr(), 'out_trade_no' => $refundData['out_trade_no'], 'out_refund_no' => $refundData['out_refund_no'], 'total_fee' => $refundData['total_fee'], 'refund_fee' => $refundData['refund_fee'], // v2 里操作员ID直接填商户号即可 'op_user_id' => $this->mchId, ]; $params['sign'] = $this->buildSign($params); $xml = $this->toXml($params); $ch = curl_init('https://api.mch.weixin.qq.com/secapi/pay/refund'); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $xml); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); curl_setopt($ch, CURLOPT_SSLCERT, $this->certPath); curl_setopt($ch, CURLOPT_SSLKEY, $this->certKeyPath); curl_setopt($ch, CURLOPT_TIMEOUT, 30); $response = curl_exec($ch); if (curl_errno($ch)) { $error = curl_error($ch); curl_close($ch); throw new RuntimeException('refund request failed: ' . $error); } curl_close($ch); $result = $this->fromXml($response); // 记录原始报文,方便排查 $this->log('refund response', $result); return $result; }逻辑说明:CURLOPT_SSL_VERIFYPEER和CURLOPT_SSL_VERIFYHOST都打开,这是生产环境必须的。CURLOPT_SSLCERT指向证书文件,CURLOPT_SSLKEY指向密钥文件。这两个路径要在类初始化时传入,我一般在配置数组里用cert_path和cert_key_path两个字段传入,构造方法里做文件存在性检查。
退款接口同步返回的result_code是SUCCESS只代表“退款申请已被受理”,不代表钱已经退回用户账户。真实退款结果会另外通过退款回调通知,也可以在订单查询接口里查refund_status字段。
4.3 退款结果可能不是异步的:回调与主动查询兜底
v2 的退款没有独立的回调接口配置,商家需要在商户平台设置退款回调地址,或者统一使用支付回调地址中的事件类型区分。微信会在退款成功后向回调地址推送通知,报文里带refund_status与out_refund_no。
实际项目里我见过两种翻车:一是只做同步处理,用户申请退款后看到“退款中”,商户后台也一直显示“退款中”,因为代码只在退款申请返回成功时更新状态;二是不做幂等,同一笔订单用户在退款页面点两次提交,产生两笔退款单,金额全是原价,导致资损。
兜底方案是主动查询。用一个定时任务,把状态为“退款中”的退款单捞出来,调用refundquery接口查询最新状态,更新到本地。查询接口也是 POST XML,但走的是普通域名,不需要证书。常见状态枚举包括:SUCCESS退款成功、REFUNDCLOSE退款关闭、PROCESSING退款处理中、CHANGE退款异常。遇到CHANGE一般要人工介入处理。
幂等设计就是out_refund_no的生成规则。同一笔订单只能用一个退款单号,用户重复点击时,先查本地表判断该out_refund_no是否已存在,已存在就直接返回第一次的退款结果,不重复请求微信接口。
5. 避坑指南:签名失败、金额翻车、回调超时和证书过期
支付模块的坑,十个里有八个是签名,剩下两个分别是金额单位和证书。这一章把我实际遇到过的问题按“现象 → 原因 → 解决”拆开写,给正在排查的你当对照表用。
5.1 现象:本地算的签名和微信返回的一模一样,线上就是报「签名错误」
这个问题的典型场景是:请求参数里多带了一个扩展字段,比如attach=订单备注,签名时把它算进去了,但实际发送的 XML 里微信收到的也是这个字段,理论上两边一致才对。但如果你查日志发现,微信返回的错误详情里有提示check sign error,而且你的参数里有一个值是空的,问题就出在空值处理上。
原因:统一下单时如果用了product_id且值为空字符串,但你的签名函数只过滤了null没过滤空字符串,就会导致参与签名的字符串里带着product_id=&,而微信端校验时也会带着这个空字段算。看起来两边算法一致,但微信端在拼接时会忽略空字段,于是两边签名字符串不一样。
解决:签名函数里统一过滤空字符串和null,一句话的事。另一个常见原因是nonce_str里带了特殊字符,导致拼接时出现歧义,方案是统一用bin2hex(random_bytes())生成纯小写字母和数字。
5.2 现象:用户明明支付成功了,订单状态还是「待支付」
原因:回调处理里有一步抛了异常,比如更新数据库时字段长度超限,或者回调入口被应用层防火墙拦截,导致没有返回SUCCESS。微信按重试策略打了十几次,你这边日志里全是异常栈,但没有任何成功记录,于是订单一直停在待支付。
解决:回调入口最外层包一个 try-catch,捕获所有异常并写日志,然后返回FAIL。同时把“更新订单状态”和“业务后续动作”拆开,前者在回调里做,后者丢队列。这样即使业务代码炸了,订单状态也能正常更新,微信不会一条通知打一整天。
5.3 现象:支付成功了,但订单金额和微信对不上
原因:金额单位混用。最常见的是数据库里存的是元,下单时直接total_fee = $amount,59.9 元变成了 59.9 分,微信当然拒收;另一种是用浮点数运算$amount * 100,PHP 浮点乘法算出来59.9 * 100 = 5989.999999999999,转整数后变成 5989 分,少一分。
解决:金额从创建订单到入参回调,全程使用整数分,禁止浮点运算。如果业务侧数据库存的是元字符串,转换时用(int)round((float)$amount * 100),再用字符串类型传给微信接口。回调比对时也强转 int 后比较,不要用==比较浮点。
5.4 现象:退款申请返回 SUCCESS,用户却说十天没到账
原因:同步受理成功和实际到账是两回事。退款是否成功,要以退款回调或主动查询结果为准。有些支付渠道的退款到账时间比较长(比如原路退回银行卡要 T+1),但如果你的程序没有处理退款结果通知,也没有主动查询,就会一直显示“退款中”。
解决:落一张退款单表,字段包含out_refund_no、refund_fee、refund_status、refund_at。退款服务方法里,微信返回return_code和result_code都成功时,写入PROCESSING状态;定时任务每五分钟拉取未终态的单子调refundquery更新状态;退款回调到了就更新为SUCCESS。人工介入前,以本地记录和微信商户平台两边的数字核对为准。
5.5 现象:同一套证书,前两个月好用,某天突然全部退款请求报「证书验证失败」
原因:商户证书过期。微信支付商户证书有效期是五年,很多人上线时配好就再也没管过,等到到期那几天,退款全部失败才开始排查。还有一个隐蔽情况:服务器上同时存在老的证书备份文件和新的证书文件,系统重新部署后加载了旧文件。
解决:部署时给证书文件配置一个到期提醒,在配置里记一个cert_expire_at,距到期前三十天日志告警。排查时先用 openssl 命令确认本地证书的有效期:
openssl x509 -in apiclient_cert.pem -noout -dates如果已过期,去微信商户平台重新申请证书,拿到新文件后替换,同时检查线上和本地部署包里的证书是否一致。常见翻车是:本地替换了,服务器上还是旧文件,部署流程里漏了同步。
6. 进阶:写出可验证的支付类与后悔药
支付模块最怕的不是写不出来,而是写完之后没法验证。一个只有线上环境才能测的支付类,改一次签名逻辑就得提心吊胆半天。我的习惯是把支付类的验证拆成两层:离线可测的纯逻辑,和线上才知道的边界行为。先让签名、验签、参数组装这些纯函数可以脱离网络和微信白名单跑通,再通过日志重放验证整条链路。
6.1 离线重放回调:把支付当纯函数来测
把微信回调的原始 XML 落日志,存成测试用例。本地写一个测试脚本,直接调用handleNotify,传入同一段 XML,观察返回值是否为SUCCESS、订单状态是否被正确更新、重复调用是否幂等。这个验证方式不需要真实支付,但能覆盖最核心的验签和状态流转。
// test_notify.php require 'WechatPayV2.php'; $config = [ 'appid' => 'wx8888888888888888', 'mch_id' => '1588888888', 'api_key'=> '你的API密钥', 'notify_url' => 'https://example.com/notify', ]; $wechatPay = new WechatPayV2($config); // 从日志文件读一段真实回调XML $xml = file_get_contents('storage/logs/notify_20240601.xml'); $response = $wechatPay->handleNotify($xml); echo $response;跑完测试后,再把同一个 XML 塞进去一次,第二次应当直接返回SUCCESS且不产生第二次订单更新——这就是幂等验证。能够离线重放,是支付类做重构时最大的后悔药。
6.2 对账兜底:一张支付流水表带来的安全感
最后建议在本地建一张支付流水表,字段至少包含:id、order_no、transaction_id、total_fee、status、paid_at、refund_no、refund_status、raw_notify。别怕冗余,raw_notify存原始回调 XML,出问题时不至于只能看摘要日志。这张表的order_no加唯一索引,transaction_id加索引,退款单号加唯一索引,所有状态变更都留操作记录。
我经历过一次凌晨两点的线上事故,用户支付成功但系统自动发货失败,当时就是靠这张流水表的raw_notify字段里保留的原始报文,回看了微信到底成功还是失败,然后手动补发。自那之后,我写支付类的第一条铁律就是:所有的请求和回调原始报文,落到本地日志,保留至少三个月。
接入支付没有捷径,把签名、金额、幂等、证书这四个点守住,这个类就稳了。希望帮到你。
本文还有配套的精品资源,点击获取