资讯动态

PHP微信支付与退款实战:APIv3签名、回调验签与避坑指南

发布时间:2026/10/15 20:44:17 来源:尧图企业网站定制
简介面向PHP开发者的微信支付与退款实战资源适配电商、在线服务等需要安全收款与售后退款的中小项目。内容围绕微信支付JSAPI模式与退款流程展开不依赖官方支付SDK而是通过封装统一下单、签名、退款等接口动作来简化接入适合具有基础PHP开发能力、希望快速集成支付功能的开发者。压缩包共3个文件均为PHP源码文件整体仅7KB按职责分为接口封装、业务调用和异步通知处理三部分结构清晰可直接对照业务流程拼接使用。已有1008人学习下载。借助示例代码开发者能理清预支付订单生成、prepay_id与JSAPI签名的组合、前端wx.chooseWXPay触发支付、退款单提交与状态查询以及回调XML解析和订单状态更新等关键链路同时还会注意到密钥安全存储、异常处理和与最新微信支付文档保持同步的实践要求。1. PHP微信支付和退款类支付不是发一个 HTTP 请求退款才是那道分水岭开发一套 PHP微信支付和退款类最容易被低估的不是下单而是退款和回调。等你把支付二维码跑通觉得不过如此时退款接口的证书签名、异步通知的重复推送、回调验签失败这几件事会立刻把你拉回现实。这篇笔记不是搬运官方文档而是把一个生产可用的支付类拆开讲签名怎么构造、回调怎么验签解密、退款怎么写才不容易翻车以及我踩过的那些坑。适合正准备接入微信支付的 PHP 开发者也适合已经能支付但退款流程一直不稳定的团队。2. 先把地基打好商户信息、证书加载与支付类的最小骨架微信支付 APIv3 的所有请求都围绕三样东西展开商户号、商户 API 证书、APIv3 密钥。这三样在代码里怎么存放、怎么加载、怎么避免硬编码决定了后续每个接口好不好维护。2.1 选 APIv3 而不是老版 API签名方式决定了后面的坑老版微信支付用的是 MD5/HMAC-SHA256 加 API Key 签名字符串参数要按字典序排序遇到中文和嵌套数组特别容易漏字段。APIv3 改用 RSA-SHA256 签名请求方用商户私钥签名微信支付返回的数据用平台证书验签。这套机制有两个好处一是签名串格式固定就是请求方法、请求路径、时间戳、随机串、请求体这五段拼在一起二是不用再处理那些排序、URL 编码之类的玄学问题。选择 APIv3 的另一个理由是退款接口只有 APIv3 版本可用老版的退款接口已经不适合新商户接入。如果你在某个老项目里见过那种塞了一堆 key 到数组里排序的签名函数建议新代码全部走 APIv3别让历史负担拖住新项目。2.2 证书与私钥的文件布局只保留三个文件在商户平台下载 API 证书后会得到两个关键文件apiclient_cert.pem和apiclient_key.pem。前者是商户证书公钥用于向微信支付证明身份后者是商户私钥用于请求签名。APIv3 密钥是一段 32 字节的字符串不是证书别把它当成私钥。我习惯把项目里的支付相关文件按下面的方式摆放证书放进独立目录权限收紧project/ ├── cert/ │ ├── apiclient_cert.pem # 商户API证书公钥侧 │ └── apiclient_key.pem # 商户API证书私钥 ├── src/ │ ├── WechatPayBase.php # 签名与HTTP底座 │ ├── PaymentService.php # 下单与回调处理 │ └── RefundService.php # 退款与退款查询不要把证书提交到代码仓库。如果用的是 Git记得把cert/写进.gitignore。证书一旦被提交上去泄漏风险就不是严格审查能补回来的。2.3 构造签名头这一段是所有请求的公共底座签名头的格式是一段以WECHATPAY2-SHA256-RSA2048开头的字符串需要带上商户号、随机串、签名、时间戳和证书序列号。签名内容就是五段拼接每段用换行符隔开。?php class WechatPayBase { protected string $mchId; protected string $appId; protected string $serialNo; protected string $privateKeyPath; protected string $apiV3Key; // 32字节 APIv3 密钥 private ?OpenSSLAsymmetricKey $privateKey null; protected function loadPrivateKey() { if ($this-privateKey null) { $this-privateKey openssl_pkey_get_private(file_get_contents($this-privateKeyPath)); } return $this-privateKey; } protected function sign(string $method, string $urlPath, string $body, int $timestamp, string $nonce): string { $message sprintf(%s\n%s\n%d\n%s\n%s\n, $method, $urlPath, $timestamp, $nonce, $body); openssl_sign($message, $signature, $this-loadPrivateKey(), sha256WithRSAEncryption); return base64_encode($signature); } protected function buildAuthHeader(string $method, string $urlPath, string $body): string { $timestamp time(); $nonce bin2hex(random_bytes(16)); $signature $this-sign($method, $urlPath, $body, $timestamp, $nonce); return sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%s,serial_no%s, $this-mchId, $nonce, $signature, $timestamp, $this-serialNo ); } }登录函数中的$urlPath是接口路径不含主机名比如/v3/pay/transactions/native。$body是请求体的原始字符串POST 请求传 JSON 字符串GET 请求传空字符串。注意 GET 请求签名时第五段为空但换行符不能省略这个细节会让不少人在查询退款接口上反复撞墙。3. 跑通下单与回调通知支付链路第一次完整闭环下单接口负责返回一个code_url给前端生成二维码真正让订单状态发生变化的是微信支付的回调通知。这两段必须一起做只做完下单不收回调等于支付流程断了一半。3.1 Native 下单生成支付二维码的完整请求Native 下单是最常见的一种方式适合 PC 端扫码支付。请求路径是/v3/pay/transactions/native请求体里的金额单位必须是分不是元。?php class PaymentService extends WechatPayBase { public function createNativeOrder(string $outTradeNo, int $totalFen, string $description): array { $payload [ appid $this-appId, mchid $this-mchId, description $description, out_trade_no $outTradeNo, notify_url https://api.example.com/pay/notify, amount [ total $totalFen, currency CNY ] ]; $path /v3/pay/transactions/native; $body json_encode($payload, JSON_UNESCAPED_UNICODE); $headers [ Authorization: . $this-buildAuthHeader(POST, $path, $body), Accept: application/json, Content-Type: application/json, User-Agent: php-wechatpay-demo/1.0 ]; $response $this-request(POST, https://api.mch.weixin.qq.com . $path, $body, $headers); if ($response[status] ! 200) { throw new RuntimeException(下单失败: . $response[body]); } return json_decode($response[body], true); } }out_trade_no是商户侧订单号建议直接用业务订单号加随机后缀确保不会重复。totalFen传入的是整数分比如 199 元就传 19900。很多团队在接入时习惯在数据库字段里存元下单时再做一次乘以 100 的转换这里建议把换算收敛到一个统一方法里避免散落在各处。3.2 解析微信支付的回调验签和解密是两个独立的动作当用户扫码支付成功后微信支付会向notify_url发送一个 POST 请求。这个请求的头部带有Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial四个字段请求体里的resource部分则是加密的订单数据。验签用平台证书的公钥解密用 APIv3 密钥这两件事不要混在一起。常见误区是拿商户私钥去验签或者拿 APIv3 密钥去验签都是错的。public function handleNotify(string $headerRaw, string $body): array { $headerMap $this-parseHeaders($headerRaw); $timestamp $headerMap[wechatpay-timestamp] ?? ; $nonce $headerMap[wechatpay-nonce] ?? ; $signature $headerMap[wechatpay-signature] ?? ; $serial $headerMap[wechatpay-serial] ?? ; // 1. 验签 $message sprintf(%s\n%s\n%s\n, $timestamp, $nonce, $body); $platformPublicKey $this-loadPlatformCert($serial); $ok openssl_verify( $message, base64_decode($signature), $platformPublicKey, OPENSSL_ALGO_SHA256 ); if ($ok ! 1) { return $this-notifyFail(验签失败); } // 2. 解密 resource $notify json_decode($body, true); $resource $notify[resource] ?? []; $decrypted $this-decryptResource( $resource[ciphertext], $resource[nonce], $resource[associated_data] ?? ); return $this-notifySuccess(json_decode($decrypted, true)); }解密用的是 AES-256-GCM密钥是 APIv3 密钥nonce 和 associated_data 都来自resource。密文里最后 16 字节是校验标签不能直接整个丢给openssl_decrypt要先拆出来。protected function decryptResource(string $ciphertextB64, string $nonce, string $aad): string { $ciphertext base64_decode($ciphertextB64); $tag substr($ciphertext, -16); $ciphertext substr($ciphertext, 0, -16); $decrypted openssl_decrypt( $ciphertext, aes-256-gcm, $this-apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad ); if ($decrypted false) { throw new RuntimeException(回调数据解密失败); } return $decrypted; }3.3 回调里的订单更新先查后更保证幂等解密后的订单数据里会有out_trade_no、trade_state、amount.total、transaction_id等字段。拿到这些数据后订单状态的更新不能直接 UPDATE要先根据out_trade_no查订单当前状态。微信支付的回调不是保证只发一次的网络抖动、服务重启都可能造成重复推送。如果回调里没有幂等处理同一个订单被更新两次金额状态就会错乱。我的处理方式是在更新之前先查一次如果订单已经是已支付状态直接返回成功不再重复执行更新逻辑。还要注意一点回调处理完后返回给微信支付的响应体必须是特定 JSON 格式状态码是 200。返回成功字符串或者不开 JSON微信支付会认为通知失败之后继续重试。4. 退款与退款查询比下单多出来的那几道坎退款接口和下单接口共用同一套签名机制但它的参数约束更多还牵涉到异步通知和状态查询处理不好钱退不回去用户投诉就来了。4.1 提交退款证书用商户私钥不是 API 密钥退款请求路径是/v3/refund/domestic/refunds请求体里必须同时带原订单号和退款单号金额部分包含退款金额、原订单金额和币种。?php class RefundService extends WechatPayBase { public function refund(string $outTradeNo, string $outRefundNo, int $refundFen, int $totalFen, string $reason ): array { $payload [ out_trade_no $outTradeNo, out_refund_no $outRefundNo, reason $reason, notify_url https://api.example.com/refund/notify, amount [ refund $refundFen, total $totalFen, currency CNY ] ]; $path /v3/refund/domestic/refunds; $body json_encode($payload, JSON_UNESCAPED_UNICODE); $headers [ Authorization: . $this-buildAuthHeader(POST, $path, $body), Accept: application/json, Content-Type: application/json ]; $response $this-request(POST, https://api.mch.weixin.qq.com . $path, $body, $headers); if ($response[status] ! 200) { throw new RuntimeException(退款失败: . $response[body]); } return json_decode($response[body], true); } }很多人第一次提交退款时会下意识传 APIv3 密钥作为签名私钥或者传商户证书公钥文件结果微信支付一直报签名错误。签名必须用apiclient_key.pem对应的私钥。这个私钥和下单用的私钥是同一个不是 APIv3 密钥。4.2 退款单号生成用前缀加原订单号保证唯一out_refund_no是商户侧退款单号必须全局唯一不能和任何历史退款单号重复。我习惯用R加原订单号再加时间戳后缀来生成。$outRefundNo R . $outTradeNo . _ . date(YmdHis);这样的好处是排查问题时能直接从退款单号看出对应的是哪笔订单并且每次都带上时间重复提交退款也会生成不同的退款单号不会触发微信支付的幂等拦截之外的问题。如果发起退款后微信支付返回了一个退款单号后续查询就要用这个返回值里的refund_id而不是每次都用自己拼接的out_refund_no。虽然两个都能查但用refund_id更准确避免商户侧有并发合并退款时定位不到。4.3 查询退款结果异步结果的兜底方案退款接口的响应里会有一个status字段常见状态有这几类状态含义后续动作SUCCESS退款成功更新订单状态为已退款PROCESSING退款处理中等待退款通知或主动查询ABNORMAL退款异常需要人工介入排查CLOSED退款关闭检查原因并重新发起退款退款不像支付那样点击后马上到账很多银行通道是延迟处理的。提交退款接口返回的status可能还是PROCESSING真正退款完成时会触发退款结果通知或者需要你主动查询。我一般会在退款提交后先记录退款单号然后通过消息队列做一次延迟查询比如 30 秒后查一次如果还是处理中再隔 2 分钟、10 分钟各查一次最多查 5 次。查询接口是 GET 请求路径是/v3/refund/domestic/refunds/{out_refund_no}签名时 body 传空字符串。public function queryRefund(string $outRefundNo): array { $path /v3/refund/domestic/refunds/ . $outRefundNo; $headers [ Authorization: . $this-buildAuthHeader(GET, $path, ), Accept: application/json ]; $response $this-request(GET, https://api.mch.weixin.qq.com . $path, , $headers); if ($response[status] ! 200) { throw new RuntimeException(退款查询失败: . $response[body]); } return json_decode($response[body], true); }注意这里buildAuthHeader的第三个参数传的是空字符串不是null也不是省略参数。签名串里要保留第五段的空行省略掉会导致签名不一致。5. 避坑指南PHP微信支付和退款类的 5 个高频问题排查我在实际项目中见过不少团队卡在同一个问题上下面这几条基本覆盖了接入时最容易踩的坑。5.1 证书序列号不匹配私钥和证书不是同一套现象请求支付或退款接口时微信支付返回CERTIFICATE_NOT_VERIFY或者提示证书序列号不匹配。原因多半是把测试商户号的证书和生产商户号私钥混用了或者本地存了多个证书文件代码加载了错误的私钥。还有一种可能是重新申请过证书但代码里的serial_no还是旧证书的序列号。解决每次更换证书后在命令行里用 OpenSSL 确认私钥和证书是同一套并重新读取证书序列号。openssl x509 -in cert/apiclient_cert.pem -noout -serial -subject openssl pkey -in cert/apiclient_key.pem -noout -text -check对比这两个命令输出的证书序列号和代码里配置的serialNo三者必须一致缺一不可。5.2 回调验签总是失败把平台证书和商户证书混在一起现象支付回调能收到但openssl_verify始终返回 0代码里还看不到任何提示。原因验签需要的是微信支付平台证书的公钥不是商户 API 证书的公钥。如果直接把apiclient_cert.pem的内容拿来做验签结果必然是失败。另一个常见原因是没有按签名串格式拼接换行符尤其是末尾那个换行。解决从微信支付官方下载平台证书保存为wechatpay_platform_cert.pem。加载时用openssl_x509_read读取公钥再传给openssl_verify。签名串必须是时间戳、随机串、请求体三个值按顺序拼接每段之间和最后都不能丢换行符。5.3 重复回调把订单状态改乱缺少通知处理中的幂等现象用户只支付了一次但订单状态被反复更新甚至出现了同一订单被处理两次的日志。原因微信支付回调为了保证送达会在未收到正确响应时多次重试。如果回调逻辑里上来就更新订单状态没有先判断当前状态重复回调就会引发重复更新。解决在回调业务处理开始处用out_trade_no查询本地订单如果已经是已支付状态直接返回成功响应不再执行后续逻辑。退款通知也同理退款记录表里如果已经有相同退款单号且状态是成功直接返回。5.4 退款少退一分钱金额单位到底用元还是分现象某些订单退款金额比原支付金额少了一分钱或者数据库里存的金额和微信支付返回的金额对不上。原因金额单位混用。数据库里用元代码里用分或者某个接口入参做了一次乘 100另一个接口没有做。这在测试环境很难发现因为小金额订单的分与元差异不明显。解决全局统一用分为单位。在支付类的最外层做一个金额工具函数任何接口入参都经过它转换。不要在每个调用方法里各自写* 100或/ 100。public static function toFen(float $yuan): int { return (int) round($yuan * 100); } public static function toYuan(int $fen): float { return $fen / 100; }5.5 回调地址收不到任何请求地址可达性和响应体格式是两大原因现象支付成功但本地接口没有任何日志或者回调接口报错后微信支付一直重试。原因一是回调地址必须是公网可达的地址且不能带自定义端口或 localhost二是回调处理逻辑里如果有异常响应体没有按微信支付要求的 JSON 格式返回微信支付会认为通知失败并持续重试。解决本地调试时可以把回调地址指到一个能公网访问的临时域名指向开发机上的 PHP 内置服务或调试接口。生产环境回调接口最外层要包一层 try-catch确保任何异常情况下都返回标准 JSON 响应。try { $result $payService-handleNotify($headerRaw, $body); http_response_code(200); echo json_encode($result, JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { // 记录异常日志 http_response_code(500); echo json_encode([code FAIL, message 处理失败], JSON_UNESCAPED_UNICODE); }6. 进阶把支付封装从能跑改成扛打支付类在项目里属于那种平时不动、动就出大事的模块。我后来做了一次重构把核心逻辑收敛到一个统一调用入口里排查问题时省了很多事。6.1 给支付类加一个统一调用入口之前每个方法里都重复写请求头组装和 HTTP 请求后面加日志、加重试都很别扭。重构后所有外部请求都走同一个方法。protected function sendRequest(string $method, string $path, array $payload []): array { $body $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE) : ; $headers [ Authorization: . $this-buildAuthHeader($method, $path, $body), Accept: application/json, Content-Type: application/json ]; $this-logger-debug(wechat request, [ method $method, path $path, body $body ]); $response $this-httpClient-request($method, $this-gateway . $path, $headers, $body); $this-logger-info(wechat response, [ status $response[status], body $response[body] ]); return $response; }这样下单、退款、查询全都走同一个入口出错时日志里能看到请求和响应的完整记录。排查问题时不再需要每个方法单独加日志。6.2 我保留的几个小习惯证书文件每次更新后第一步先在命令行验证私钥和证书是否匹配这个习惯帮我避免了好几次因为证书不配套导致的线上事故。回调处理的入口处永远先查一次订单状态再做业务更新这已经不是优化建议而是底线要求。退款和支付的服务类拆开各自独立避免了把 APIv3 密钥误当退款签名私钥的低级失误。另一个值得养成的小习惯是所有金额字段传入时都附加单位注释比如int $totalFen的变量名直接带上 Fen 后缀或者干脆用强类型声明加注释防止同事在后续维护时把分当成元。希望这些习惯能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑