简介:这份资源面向需要在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 拼 key,V3 改成用商户私钥对「请求方法\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 | 商户号 | 字符串,别传成 int |
| description | 商品描述 | 最长 127 字符 |
| out_trade_no | 商户订单号 | 6-32 位,同一商户号下唯一 |
| notify_url | 回调地址 | 必须公网可访问,不能带参数 |
| amount.total | 金额,单位分 | 整数,1 元传 100 |
| payer.openid | 用户 openid | JSAPI 必传 |
调用时把请求体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_id=xxx\n,这一步和请求签名是两套逻辑,别复用同一个方法。
2.3 回调验签与解密:黑匣子就在这里
支付结果通知进来时,请求头带Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial,body 是加密的。验签要用微信平台证书的公钥,解密用 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}?mchid=xxx查询接口的签名方法和 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?type=pay。
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 2>&1对账脚本里注意账单文件的下载链接有效期只有几分钟,拿到后要立刻下载,别存起来第二天再用。
5.3 一个验证类是否可靠的小技巧
接完支付别急着上线,先用微信提供的沙箱环境或者 1 分钱真实订单跑一遍完整链路:下单 → 支付 → 回调 → 退款 → 退款回调。每一步都把原始请求和响应打到日志里,重点看签名串和实际发送的 body 是否一致。我习惯在类的request方法里加一个 debug 开关,打开时把$message和$body都写进文件,验签失败时直接对比就能定位。
从那以后我每次接新的微信支付项目,都会先把回调验签和对账脚本这两块跑通再写业务逻辑,因为这两处一旦出问题,后面所有订单状态都是错的,补数据能补到怀疑人生。希望这份拆解帮到你,少走点我当年踩过的弯路。
本文还有配套的精品资源,点击获取