ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP微信支付类封装:JSAPI/Native下单、回调验签与退款避坑指南

PHP微信支付类封装:JSAPI/Native下单、回调验签与退款避坑指南 简介这是面向PHP开发者的微信支付与退款功能实现资源聚焦JSAPI支付场景无需集成微信官方开发包通过原生接口调用即可完成从预支付订单、签名生成到前端拉起支付及退款处理的完整流程尤其适合电商、在线缴费等需要快速接入支付能力的项目。压缩包仅7KB包含3个PHP文件分别是支付主逻辑类、参数配置类及异步回调通知处理脚本文件划分清晰便于直接嵌入现有业务代码。目前已有1006人学习/下载。资源附带的示例代码覆盖统一下单获取预支付订单标识、生成JSAPI签名、调用前端支付方法发起支付、提交退款申请、查询退款状态以及处理异步回调等关键环节并对支付密钥安全存储等注意事项给出提示可帮助开发者避开常见坑点快速完成支付与退款功能的调试和上线部署。 做过微信支付接入的PHPer大概率都有过这么一段纠结期文档翻了一堆接口看着也不复杂可真把代码写出来总有那么几个地方对不上。比如为什么官方SDK越来越大自己只想维护一个轻量的支付和退款类为什么同一个参数在文档里一会儿是total_fee一会儿又是amount为什么退款就比支付多出一道证书门槛。这篇方案沉淀了我多次接入后的一个可复用PHP微信支付类覆盖JSAPI与Native下单、回调验签、退款申请与结果查询再附上生产环境跑了大半年才踩出来的真实问题。适合刚接手支付需求、想快速跑通全流程的PHPer也适合不想被SDK绑架、想自己接管核心逻辑的开发者。很多时候官方文档写到了但没写透写透了的那部分又没人提醒你哪里才是真正的坑。1. 接入前先理清四件事模式、版本、密钥和金额单位1.1 先确认你的支付场景对应哪种模式微信支付没有一套接口通吃所有场景模式选错会让后续所有请求都白搭。最容易混淆的是JSAPI、Native和H5这三种。JSAPI在微信内置浏览器里发起支付也叫公众号支付。用户从公众号菜单或网页里进入页面通过wx.chooseWXPay弹起收银台。前提是公众号必须认证而且要拿到用户openid。Native扫码支付。电脑端网页或线下屏幕展示二维码用户拿微信扫一扫完成付款。服务端下单后拿到code_url再把它生成二维码展示。H5非微信浏览器中调起微信支付。比如用户在手机浏览器打开你的链接点击支付会跳转至微信客户端完成付款。这个对UA判断比较严格审核卡得也严。小程序支付本质上也是JSAPI的一种只不过用的是小程序的appid和会话里的openid。我的项目因为同时有公众号商城和PC端订单所以类里同时实现了JSAPI和Native通过下单时传入不同type决定走哪条分支。其余场景逻辑相同只是返回给前端的参数不同。1.2 APIv2还是APIv3别混着用微信支付现在有v2和v3两套接口体系。v3是新的用JSON、证书序列号和微信支付平台证书验签v2是老牌XML接口用MD5或HMAC-SHA256签名。网上大量遗留代码还是v2搜资料时要特别注意区分。我的建议是新项目直接上APIv3官方在逐步收紧v2权限很多新功能只开放v3。但如果你的系统是维护老项目比如2019年之前上线的商城大概率文件里全是XML签名逻辑那继续沿用v2是成本最低的选择。文章示例代码主要按v2写原因是v2的签名逻辑用一小段函数就能讲透v3的证书验证体系更适合单独开一篇。文末我会说明后面迁移v3需要动哪些点。1.3 密钥体系商户号、AppID和APIv2密钥的关系这一块很多新手会卡住。简单说商户号mch_id是商户身份AppID是公众号或小程序的身份证APIv2密钥32位字符串是你在商户平台自行设置的签名密钥用来给请求参数做签名。三个值缺一不可其中AppID和mch_id是明文随请求发出的密钥只用于本地签名计算永远不能出现在请求参数里。对应到类里的配置就是$config [ app_id wx1234567890abcdef, mch_id 1900000001, api_key 你的32位APIv2密钥, notify_url https://api.example.com/pay/notify, ];要注意APIv2密钥和APIv3密钥在商户平台里是两个独立配置位置不同、用途不同。如果项目里同时有v2和v3接口两套密钥都要配不能混用。我见过有同事把v3密钥粘到v2配置里签名一晚上没通过第二天才发现问题。1.4 金额按分算四舍五入要小心微信支付所有金额字段都是整数单位是分。用户在页面看到的价格是元比如29.9元传给微信的total_fee就得是2990。这里最常见的坑是浮点运算精度问题PHP里直接算很容易出现0.10.2不等于0.3的情况。稳妥做法是业务层全部用整型分来计算只有界面展示时才转成元。如果你不得不从元转分用字符串函数处理而不是直接乘法function yuanToFen($yuan) { return intval(strval(round(floatval($yuan) * 100))); }这个看似简单的转换在促销叠加满减券时最容易翻车差一分钱微信都会返回金额不一致。2. 支付类核心方法拆解下单组装、签名与请求封装2.1 类结构设计我习惯用一个WechatPay类封装所有微信支付v2相关操作构造方法接收配置数组内部统一处理签名、请求和回调验签。对外只暴露下单、验签、退款这几个方法调用方不需要关心XML长什么样、证书放哪。class WechatPay { private $appId; private $mchId; private $apiKey; private $certPath; private $keyPath; private $notifyUrl; public function __construct(array $config) { $this-appId $config[app_id]; $this-mchId $config[mch_id]; $this-apiKey $config[api_key]; $this-certPath $config[cert_path] ?? ; $this-keyPath $config[key_path] ?? ; $this-notifyUrl $config[notify_url] ?? ; } }cert_path和key_path只有退款时才用到后面会单独讲。2.2 统一下单参数组装与签名v2的统一下单接口地址是https://api.mch.weixin.qq.com/pay/unifiedorder请求体是XML。参数里有几个固定的appid、mch_id、nonce_str随机字符串、sign_type、body、out_trade_no、total_fee、spbill_create_ip、notify_url、trade_type。JSAPI要多传openidNative不用传openid。签名的规则把参数按key的ASCII码升序排列拼成key1value1key2value2的字符串末尾再拼上key你的API密钥然后做MD5或HMAC-SHA256结果转大写放进XML的sign字段。核心逻辑如下private function sign(array $data, $type MD5) { ksort($data); $stringA []; foreach ($data as $k $v) { if ($v ! !is_null($v) $k ! sign) { $stringA[] $k . . $v; } } $stringB implode(, $stringA) . key . $this-apiKey; if ($type HMAC-SHA256) { return strtoupper(hash_hmac(sha256, $stringB, $this-apiKey)); } return strtoupper(md5($stringB)); }注意两个容易错的位置一是签名拼接时value不能urlencode要原样拼接二是过滤空字符串时不能误伤比如字段值为0时不能被当空值去掉否则签名永远对不上。2.3 curl请求封装里的隐形坑微信支付v2要求Post一个XMLHeader里带上Content-Type: text/xml。但真正容易坑人的是SSL/TLS版本和超时时间。微信服务器要求TLS 1.2以上如果你的服务器OpenSSL版本太旧或PHP的curl扩展默认起的是老TLS会收到类似unknown protocol的错误。我在封装时习惯显式指定private function postXml($url, $xml) { $ch curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST true, CURLOPT_POSTFIELDS $xml, CURLOPT_RETURNTRANSFER true, CURLOPT_HEADER false, CURLOPT_HTTPHEADER [Content-Type: text/xml], CURLOPT_SSL_VERIFYPEER true, CURLOPT_SSL_VERIFYHOST 2, CURLOPT_CONNECTTIMEOUT 10, CURLOPT_TIMEOUT 30, ]); $response curl_exec($ch); // 这里要记录 curl_error($ch)排查问题全靠它 }SSL_VERIFYPEER建议保持true方便调试时可以临时关掉但生产环境开启是底线。另外如果用自建机房或国内云服务器注意curl请求的出口IP要和商户平台配置的IP白名单一致否则微信会拒绝请求。2.4 下单成功后按场景组装前端参数统一下单成功后微信返回prepay_id。不同场景要从前端发起支付需要的参数不一样JSAPI前端要调wx.chooseWXPay需要timestamp、nonceStr、package值prepay_idxxx、signType以及二次签名paySign。这个签名要在后端生成参数顺序和下单签名略有差异。Native直接把返回的code_url输出成二维码图片用户扫码后微信后台会异步通知支付结果。小程序和JSAPI类似只不过调用的是wx.requestPayment。所以类里可以在unifiedOrder返回prepay_id后再用一个buildJsapiParams方法生成前端参数。这里最容易漏的是package值前面那个prepay_id前缀少了它前端直接弹不出收银台。3. 回调验签与订单二次校验覆盖率最高的翻车现场3.1 接收回调与XML解析支付成功后微信会向notify_url推送一个XML数据包。注意这个回调的数据不在$_POST里而在POST body的原始流中所以第一步要用file_get_contents(php://input)拿到原始内容再转成数组。public function handleNotify() { $xml file_get_contents(php://input); $data $this-xml2Array($xml); if (!$this-verifySign($data)) { return $this-respond(false, sign error); } // 业务处理... return $this-respond(true, OK); }xml2Array建议用simplexml_load_string并强制转数组注意微信返回的CDATA节点。如果自己写正则解析务必处理CDATA不然取到的值会带一层壳。3.2 验签逻辑和下单签名同源回调验签的算法和下单签名一致把微信回调返回的参数去掉sign字段按ASCII排序拼接加key做MD5比较是否等于sign。这个逻辑在微信支付里是通用的所以类里专门抽了一个verifySign方法public function verifySign(array $data) { if (empty($data[sign])) { return false; } $sign $data[sign]; unset($data[sign]); return $this-sign($data, $data[sign_type] ?? MD5) $sign; }有人会问回调验签有什么用微信反正是从HTTPS接口推送的为什么还要验因为通知URL可能被模拟请求而且一旦接口被扫描工具探测到会收到大量伪造XML验签是拦截这些垃圾请求的第一道闸门。3.3 业务二次校验不能省验签通过只代表消息来自微信不代表订单可以发货。真正重要的是在回调里做业务校验我总结为三个必须必须查自己的订单表确认out_trade_no存在不能拿着一个不认识的订单号就更新数据。必须比对金额微信回调的total_fee要等于订单表里的应收金额少一分都不行。必须核对商户号和appid防止别的商户号回调串到你这。这些校验逻辑在文档里其实都有提但我见过不少项目只做了验签就执行发货结果出过严重的资损问题。尤其是金额比对网上很多教程把它写在注释里现实里一上线就忘。我把这三个校验放在同一个方法里顺序执行任何一步不过直接返回失败应答。3.4 应答格式与重试机制微信支付回调有重试机制如果你的接口没有正确应答微信会在数秒后重新发送最多重试数次。成功应答的格式是xmlreturn_code![CDATA[SUCCESS]]/return_codereturn_msg![CDATA[OK]]/return_msg/xml失败应答则是return_code为FAIL。这里有个反直觉的点即使你业务处理成功但应答失败微信也会重发导致回调方法重复执行。所以幂等性设计必须做最简单是给订单表加一个pay_status字段已经变成已支付状态的订单重复回调直接返回成功应答不重复处理业务。4. 退款接口实现证书加载、金额核对与结果确认4.1 为什么支付不需要证书而退款需要支付请求是商户收钱风险可控退款是商户把已经进入结算流程的钱退回去微信需要验证商户合法身份所以退款接口要求使用客户端证书也就是双向HTTPS。这个证书是商户平台下载的apiclient_cert.pem和apiclient_key.pem不是网页证书也不是微信支付平台证书。很多人在这一步栽跟头下载下来直接把证书文件丢在web目录里安全问题先不说curl加载证书的路径配置不对也会报错。我的建议是证书放在web目录之外的受保护目录PHP配置里写入绝对路径。4.2 curl加载证书与退款请求封装退款申请接口是https://api.mch.weixin.qq.com/secapi/pay/refund需要在curl里加载证书curl_setopt($ch, CURLOPT_SSLCERTTYPE, PEM); curl_setopt($ch, CURLOPT_SSLCERT, $this-certPath); curl_setopt($ch, CURLOPT_SSLKEYTYPE, PEM); curl_setopt($ch, CURLOPT_SSLKEY, $this-keyPath);这里还有一个容易忽略的退款接口的请求IP同样要加入白名单否则返回不合法的请求来源。另外如果你的服务器是多机部署退款请求可能落在与配置证书不同的机器上需要注意每台机器都要有证书文件。4.3 退款参数与金额核对细节退款参数相比下单多了几个关键字段out_trade_no原商户订单号和out_refund_no不能混淆。out_refund_no本次退款单号由商户生成每一笔退款唯一。total_fee原订单总金额分。refund_fee退款金额分可以是部分退款。refund_desc退款原因必填。退款金额校验是业务层的事也是安全底线。我的做法是先读取订单当前已退款金额累计用订单总金额 - 已退款累计算出可退余额只允许在这个范围内申请退款。否则一个退款请求重复提交可能把订单退穿。4.4 退款结果不实时轮询与回调并存退款接口不是同步返回退款成功而是返回受理成功最终结果通过两路返回一路是退款结果通知需要在商户平台配置回调地址另一路是通过退款查询接口主动拉取。所以退款状态的最终确认不能依赖退款申请接口的返回。我在生产环境的做法是退款申请后立即在本地记录退款单状态为处理中同时起一个计划任务每半小时查一次退款查询接口把确实退款成功的单据改成已退款。如果退款通知也配置了就把它当作辅助的即时通道两边都用最终以查询结果为准。原因是通知偶尔会有延迟甚至丢失只靠通知不靠谱。5. 生产环境半年踩坑记录查询兜底、超时关单与密钥管理5.1 订单查询接口是最后的兜底支付回调虽然是主要通知渠道但现实里会有各种意外回调服务器故障、内网防火墙临时抽风、接口超时导致微信重试队列堆积。所以每一笔支付订单都应该有主动查询能力。在用户端我设计了刷新订单状态按钮调用查询订单接口去微信侧拉实时状态在服务端计划任务每分钟扫一遍待支付超过30秒的单子调用查询接口确认。public function queryOrder($outTradeNo) { $data [ appid $this-appId, mch_id $this-mchId, out_trade_no $outTradeNo, nonce_str $this-nonce(), ]; $data[sign] $this-sign($data); $xml $this-array2Xml($data); $response $this-postXml(https://api.mch.weixin.qq.com/pay/orderquery, $xml); return $this-xml2Array($response); }这里强调一点查询订单里的trade_state是SUCCESS才能把本地订单置为支付成功其他状态都不能当作成功处理。特别是有个NOTPAY和CLOSED语义完全不同别理解错。5.2 超时未支付的订单要主动关单用户发起支付后不付款订单会一直挂着。微信侧订单默认有效期2小时但对商户来说库存、优惠券等资源不能一直占用。所以我在类里也实现了关单方法下单后10分钟未支付就调用closeorder关闭。关单后订单不可再支付前端需要提示用户重新下单生成新订单。这里要注意如果用户已经支付成功但回调还没到这时调关单接口会失败所以关单前要先调用查询接口确认订单状态是NOTPAY才关单顺序搞反会让一笔已支付订单被错误关闭。5.3 APIv2密钥忘了怎么办经常有人搜apiv2密钥已经设置了但是忘记了怎么查看——这个问题的答案很直接APIv2密钥设置成功后是不可查看的只能重置。商户平台里找到账户中心 - API安全 - APIv2密钥入口点击重置会要求设置新的32位密钥设置完成立即生效但所有用到旧密钥的服务器都要同步改配置不然签名全部失败。我建议把密钥放到配置文件或环境变量中不要在代码里硬编码更不要提交到Git仓库。这个属于基础安全习惯但踩过坑的人都懂。5.4 重复回调与状态幂等最后聊一下重复回调。微信支付的回调重试加上你自己写的订单查询任务同一笔订单的支付成功事件可能被触发多次。如果处理逻辑没有幂等结果就是重复加积分、重复发卡、甚至重复发货。我的方案是数据库订单表增加pay_notify_count字段每次处理回调先记录通知次数然后检查订单状态如果已经是已支付就直接返回成功应答不再执行任何业务操作。同时所有金额变动相关的操作放在数据库事务里更新时带上条件WHERE payment_status 0更新成功行数为0则说明已经被处理过了。我自己的项目跑了大半年真正出问题的几次几乎都出在以为回调是最可靠的这个错觉上。回调会丢、会重、会乱序查询接口才是最终真相。所有资金相关的判断最后都以查询结果为准。最后再分享一个日常维护的心得微信支付的日志一定要全量保留包括请求XML、返回XML、验签结果、curl错误码。别觉得这些数据啰嗦真正排查问题的时候十个里有九个靠日志就能定位。我习惯按日期分目录记录保留至少90天复盘每一笔异常订单时日志就是唯一的证据链。这个习惯帮我省了太多时间建议你也从一开始就养成。本文还有配套的精品资源点击获取
返回列表