ARTICLE DETAIL

资讯详情

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

PHP微信支付V3与退款类封装实战:签名验签、AES-GCM解密与对账避坑指南

PHP微信支付V3与退款类封装实战:签名验签、AES-GCM解密与对账避坑指南 简介这份资源面向需要在PHP项目中接入微信支付的开发者尤其适合电商、在线服务类网站的中初级程序员解决JSAPI支付与退款流程实现繁琐、依赖官方SDK的问题。压缩包共3个文件均为php源码整体约7KB涵盖统一下单、签名生成、前端JS调起支付、退款申请、退款查询及异步回调通知处理等核心环节结构紧凑便于直接参考。已有1008人学习下载说明其在实际开发中具备一定参考价值。读者可从中获得一套不依赖微信官方SDK的轻量实现思路理解prepay_id获取、JSAPI签名规则、退款单号与状态查询的完整链路并借助示例代码快速集成到自己的项目中。同时资源也提示了支付密钥保管、敏感信息加密及接口规范同步等安全注意事项帮助开发者在简化流程的同时兼顾安全性与可维护性。1. 从一笔订单说起PHP 微信支付和退款类到底封装了什么上周帮朋友处理一个商城后台的对账问题订单表里躺着十几条状态卡在「已支付未回调」的记录财务那边催着退款技术这边翻日志发现是异步通知没验签通过。这种场景在 PHP 项目里太常见了——微信支付 V3 接口本身不复杂难的是把下单、回调、退款、对账这几条链路串成一个能复用的类而不是每次接新项目都从头抄一遍官方示例。这份 PHP 微信支付和退款类核心就是把微信支付 V3 的商户下单、支付结果通知验签解密、申请退款、退款结果通知、查询订单这几件事收进一个类里对外暴露几个方法调用方不用关心签名串怎么拼、AES-GCM 怎么解、平台证书怎么下载轮换。适合谁用手上是 PHP 项目原生或 ThinkPHP、Laravel 这类框架都行需要接微信支付但又不想引一整套重量级 SDK或者已经引了官方 SDK 但被它的目录结构和依赖搞得头大的人。下面按「这个类怎么落地 → 参数怎么配 → 哪里会翻车」的顺序拆开讲。2. 下单与回调把 V3 签名串和 AES-GCM 解密讲透2.1 为什么 V3 的签名逻辑必须自己理一遍微信支付 V3 和 V2 最大的区别是签名机制换了。V2 用 MD5/HMAC-SHA256 拼 keyV3 改成用商户私钥对「请求方法\nURL\n时间戳\n随机串\n请求体」这五段拼成的串做 SHA256withRSA 签名再把签名、时间戳、随机串、证书序列号塞进 Authorization 头。很多人直接调 SDK 不关心这层一旦回调验签失败就完全不知道从哪查。这个类里签名部分通常长这样我按常见实现写一版?php class WxPayV3 { private $mchId; // 商户号 private $serialNo; // 商户证书序列号 private $privateKey; // 商户私钥内容不是路径 private $apiV3Key; // APIv3 密钥32 位 private $appId; public function __construct($config) { $this-mchId $config[mch_id]; $this-serialNo $config[serial_no]; $this-privateKey $config[private_key]; $this-apiV3Key $config[api_v3_key]; $this-appId $config[app_id]; } // 生成请求签名 private function buildAuthHeader($method, $url, $body) { $timestamp time(); $nonce bin2hex(random_bytes(16)); $message $method . \n . $url . \n . $timestamp . \n . $nonce . \n . $body . \n; openssl_sign($message, $sign, $this-privateKey, OPENSSL_ALGO_SHA256); $sign base64_encode($sign); return sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%s,serial_no%s, $this-mchId, $nonce, $sign, $timestamp, $this-serialNo ); } }逻辑说明$message末尾那个\n是最容易漏的官方文档里写的是每行以\n结尾包括请求体那一行。参数上private_key要传证书文件的内容而不是路径用file_get_contents读进来serial_no是商户 API 证书的序列号不是平台证书的这两个搞混签名必失败。2.2 统一下单接口的调用与参数JSAPI 下单公众号/小程序内支付是最常用的场景请求体关键字段如下参数含义注意点appid公众号或小程序 appid必须和商户号绑定mchid商户号字符串别传成 intdescription商品描述最长 127 字符out_trade_no商户订单号6-32 位同一商户号下唯一notify_url回调地址必须公网可访问不能带参数amount.total金额单位分整数1 元传 100payer.openid用户 openidJSAPI 必传调用时把请求体json_encode后传给签名方法注意json_encode不要加JSON_UNESCAPED_UNICODE之外的多余选项否则签名串和实际发送的 body 不一致服务端验签直接拒。public function jsapiPay($outTradeNo, $openid, $totalFee, $desc) { $url https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi; $body json_encode([ appid $this-appId, mchid $this-mchId, description $desc, out_trade_no $outTradeNo, notify_url https://your.domain.com/notify.php, amount [total $totalFee, currency CNY], payer [openid $openid], ], JSON_UNESCAPED_UNICODE); $headers [ Authorization: . $this-buildAuthHeader(POST, /v3/pay/transactions/jsapi, $body), Accept: application/json, Content-Type: application/json, User-Agent: your-app/1.0, ]; // curl 发送返回 prepay_id }拿到prepay_id后还要再签一次名给前端调起支付签名串是appId\ntimeStamp\nnonceStr\nprepay_idxxx\n这一步和请求签名是两套逻辑别复用同一个方法。2.3 回调验签与解密黑匣子就在这里支付结果通知进来时请求头带Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serialbody 是加密的。验签要用微信平台证书的公钥解密用 APIv3 密钥做 AES-256-GCM。public function handleNotify($headers, $rawBody) { // 1. 验签用平台证书公钥 $message $headers[Wechatpay-Timestamp] . \n . $headers[Wechatpay-Nonce] . \n . $rawBody . \n; $ok openssl_verify( $message, base64_decode($headers[Wechatpay-Signature]), $this-getPlatformPublicKey($headers[Wechatpay-Serial]), OPENSSL_ALGO_SHA256 ); if ($ok ! 1) { throw new Exception(验签失败); } // 2. 解密 resource $data json_decode($rawBody, true); $cipher base64_decode($data[resource][ciphertext]); $nonce $data[resource][nonce]; $aad $data[resource][associated_data]; $plain openssl_decrypt( $cipher, aes-256-gcm, $this-apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad ); return json_decode($plain, true); }openssl_decrypt的$tag参数是引用传出的GCM 模式下必须传很多人漏了导致解密返回 false。平台证书要定期下载更新序列号对不上就验签失败这是回调链路最常见的坑。3. 退款链路申请、回调与状态机怎么对齐3.1 退款接口的参数与幂等设计退款接口是POST /v3/refund/domestic/refunds关键参数和下单不同金额、订单号都要重新组织参数含义注意点out_trade_no原支付订单号和 transaction_id 二选一out_refund_no商户退款单号唯一重试要复用同一个amount.refund退款金额分不能超过原订单amount.total原订单金额分必须和支付时一致notify_url退款回调可选不传就靠主动查询幂等这块血泪经验退款请求超时后不要换out_refund_no重试微信侧可能已经受理换号会导致重复退款。正确做法是用同一个退款单号重试微信会返回同一笔退款的状态。public function refund($outTradeNo, $outRefundNo, $refundFee, $totalFee) { $url https://api.mch.weixin.qq.com/v3/refund/domestic/refunds; $body json_encode([ out_trade_no $outTradeNo, out_refund_no $outRefundNo, amount [refund $refundFee, total $totalFee, currency CNY], ], JSON_UNESCAPED_UNICODE); // 签名发送返回 refund_id 和 status }返回的status有SUCCESS、PROCESSING、CLOSED、ABNORMAL几种PROCESSING不代表失败要等退款回调或主动查询确认。3.2 退款回调与本地状态机退款回调的验签解密逻辑和支付回调完全一样只是event_type是REFUND.SUCCESS或REFUND.ABNORMAL。本地订单表建议单独存退款状态不要和支付状态混在一个字段里否则对账时很难区分「支付成功但退款中」和「支付成功且已退款」。常见做法是订单主表存支付状态退款记录单独一张表用out_refund_no做唯一索引回调进来先查这张表存在就更新状态不存在就插入。这样即使回调重复推送也不会产生脏数据。3.3 主动查询兜底回调不是 100% 可靠网络抖动、服务器重启都可能丢通知。生产环境一定要加一个定时任务把PROCESSING状态的退款单和「已支付未回调」的订单捞出来主动查// 查询退款GET /v3/refund/domestic/refunds/{out_refund_no} // 查询订单GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchidxxx查询接口的签名方法和 POST 一样只是 method 传 GET、body 传空字符串。注意 URL 里的 query string 要包含在签名串的 URL 部分里漏了会 401。4. 避坑与排查这几处翻车率最高4.1 回调验签一直失败现象日志里openssl_verify返回 0回调处理直接抛异常。原因通常是平台证书没更新或者验签用的Wechatpay-Serial对应的证书本地没有。解决实现平台证书自动下载用GET /v3/certificates拉取解密后按序列号缓存每次验签前先按请求头里的序列号找证书找不到就重新下载一次。4.2 金额单位搞错导致退款金额异常现象退款 1 元结果退了 100 元或者报「退款金额超过订单金额」。原因微信所有金额单位是分但前端传过来往往是元。解决在类里统一约定入参是分前端传元的话在控制器层乘 100 再传进来别在类内部做隐式转换否则调用方永远搞不清该传什么。4.3 私钥格式不对导致签名报错现象openssl_sign返回 false 或报key type not supported。原因私钥文件里带了多余的空格、换行或者传的是文件路径而不是内容。解决用file_get_contents读证书内容确保以-----BEGIN PRIVATE KEY-----开头、-----END PRIVATE KEY-----结尾中间不要手动加换行。4.4 回调地址带参数被拒现象下单接口返回「notify_url 格式错误」。原因微信要求notify_url必须是纯 URL不能带 query string。解决把业务参数放到路径里比如/notify/pay和/notify/refund分开别用/notify?typepay。4.5 并发退款导致重复处理现象同一笔订单短时间内收到两次退款回调本地扣了两次库存或记了两条退款记录。原因回调可能重复推送且并发到达。解决用out_refund_no做数据库唯一索引插入冲突就忽略或者用INSERT ... ON DUPLICATE KEY UPDATE保证幂等。5. 进阶把类接进框架与对账脚本5.1 在 ThinkPHP/Laravel 里注册成服务原生类直接new也能用但配置散落各处不好维护。Laravel 里可以写个 ServiceProvider 把它注册成单例配置从config/wechat.php读// config/wechat.php return [ mch_id env(WX_MCH_ID), serial_no env(WX_SERIAL_NO), private_key file_get_contents(storage_path(cert/apiclient_key.pem)), api_v3_key env(WX_API_V3_KEY), app_id env(WX_APP_ID), ];ThinkPHP 的话在app/provider.php里绑定或者干脆写个助手函数wxpay()返回单例。关键是把证书路径和密钥放环境变量别硬编码进类文件否则换商户号要改代码。5.2 用对账接口做每日核对微信提供GET /v3/bill/tradebill下载交易账单返回的是 CSV 压缩包。我一般写个脚本每天凌晨拉前一天的账单和本地订单表按out_trade_no比对差异记录写进一张reconcile_diff表人工处理。这一步能兜住回调丢失、金额不一致、状态不同步这几类问题比单纯依赖回调靠谱得多。# 定时任务示例 0 3 * * * /usr/bin/php /www/script/reconcile.php /var/log/wx_reconcile.log 21对账脚本里注意账单文件的下载链接有效期只有几分钟拿到后要立刻下载别存起来第二天再用。5.3 一个验证类是否可靠的小技巧接完支付别急着上线先用微信提供的沙箱环境或者 1 分钱真实订单跑一遍完整链路下单 → 支付 → 回调 → 退款 → 退款回调。每一步都把原始请求和响应打到日志里重点看签名串和实际发送的 body 是否一致。我习惯在类的request方法里加一个 debug 开关打开时把$message和$body都写进文件验签失败时直接对比就能定位。从那以后我每次接新的微信支付项目都会先把回调验签和对账脚本这两块跑通再写业务逻辑因为这两处一旦出问题后面所有订单状态都是错的补数据能补到怀疑人生。希望这份拆解帮到你少走点我当年踩过的弯路。本文还有配套的精品资源点击获取
返回列表