简介:这份PHP微信支付与退款类资源面向电商及在线服务网站的开发者,尤其是希望在不集成微信官方支付SDK的前提下快速实现支付与退款功能的初中级PHP程序员。资源包共3个文件,均为php源码,压缩后约7KB,涵盖统一下单、JSAPI支付签名生成、前端wx.chooseWXPay调用、退款申请提交、退款状态查询以及异步回调通知处理等核心环节,示例代码结构清晰,便于直接嵌入现有项目。已有1008人学习下载,说明其在实际开发中具备一定参考价值。读者可通过阅读源码掌握预支付订单参数组织、签名安全规范、XML回调解析与订单状态更新等关键实现思路,快速将微信支付JSAPI与退款流程落地到自己的业务中,同时理解敏感信息加密与密钥保管的注意事项,减少对接微信支付接口时的试错成本。
1. PHP 微信支付和退款类:从下单到退款到账,一条能跑通的链路
电商项目做到收尾阶段,最容易被卡住的不是商品逻辑,而是钱怎么进来、怎么退回去。PHP 微信支付和退款类这套东西,本质是把微信支付 V3 的下单、回调验签、退款申请、退款结果通知串成一个可复用的类,让业务代码只关心订单号、金额和状态,不用每次重写签名和证书加载。它适合正在用 PHP 做商城、知识付费、预约系统,且需要自己掌控资金流的开发者。热搜里「微信支付接口」被反复搜,说明大量人卡在接口对接这一步,而不是业务本身。这篇按我实际落地的顺序讲:先讲清 V3 和 V2 的差别与选型,再给下单和退款的完整代码,最后把踩过的坑摊开。读完你能拿到一套能直接改参数就用的结构,而不是一堆散落的示例。
2. 选 V3 还是 V2:签名、证书和回调的差别先搞明白
2.1 为什么现在新项目一律上 V3
微信支付 V2 用的是 MD5 或 HMAC-SHA256 拼串签名,密钥是一串 32 位 API 密钥,配置简单但安全性弱,且官方早已不再主推。V3 换成了 SHA256-RSA 非对称签名,请求要用商户私钥签名,回调要用微信平台证书验签,敏感字段还用 AES-256-GCM 加密。多出来的成本是证书管理,换来的是防篡改和防伪造回调。我一般新项目直接上 V3,除非对接的是十年前的老系统,改造成本高于收益。
V3 的请求签名规则是:取 HTTP 方法、URL 路径、时间戳、随机串、请求体,拼成五行字符串,用商户私钥做 SHA256-RSA 签名,再 Base64 放进 Authorization 头。回调验签则反过来,用微信平台证书公钥验证Wechatpay-Signature。这里最容易翻车的是 URL 路径必须带 query string,很多人只取了 path,导致签名对不上。
2.2 证书和密钥的准备清单
落地前先把这几样东西备齐,缺一个都跑不起来:
| 文件/参数 | 来源 | 用途 |
|---|---|---|
| 商户号 mchid | 商户平台 | 标识商户身份 |
| 商户 API 证书 apiclient_cert.pem | 商户平台下载 | 请求签名 |
| 商户 API 私钥 apiclient_key.pem | 商户平台下载 | 请求签名 |
| 商户 API 证书序列号 | 证书详情页 | 放进 Authorization |
| APIv3 密钥 | 商户平台设置 | 回调解密 |
| 微信平台证书 | 通过接口下载 | 回调验签 |
平台证书不是固定文件,它会轮换,所以正确做法是用GET /v3/certificates接口拉取并缓存,而不是下载一次写死。我见过有人把平台证书硬编码进代码,证书一换回调全部验签失败,订单状态永远停在待支付。
2.3 用 PHP 加载证书并生成签名头
下面这段是签名核心,独立成一个方法,下单和退款都复用它:
<?php class WxPaySigner { private string $mchId; private string $serialNo; private string $privateKey; public function __construct(string $mchId, string $serialNo, string $keyPath) { $this->mchId = $mchId; $this->serialNo = $serialNo; // 读取商户私钥,注意文件权限,别让 web 目录能直接访问 $this->privateKey = file_get_contents($keyPath); } // 生成 Authorization 头 public function buildAuthHeader(string $method, string $urlPath, string $body): string { $timestamp = time(); $nonce = bin2hex(random_bytes(16)); // 五行拼串,最后一行是请求体,GET 请求体为空字符串 $message = $method . "\n" . $urlPath . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; openssl_sign($message, $signature, $this->privateKey, OPENSSL_ALGO_SHA256); $sign = base64_encode($signature); return sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"', $this->mchId, $nonce, $sign, $timestamp, $this->serialNo ); } }逻辑说明:$message的五行顺序不能错,$urlPath必须包含/v3/前缀和 query string,比如/v3/pay/transactions/jsapi。参数说明:$serialNo是商户证书序列号,不是平台证书序列号,这两个搞混签名必失败;random_bytes生成随机串,别用rand,长度和随机性不够会被拒。私钥文件建议放在 web 根目录之外,用绝对路径读取。
3. 下单接口:JSAPI 支付从组装参数到拿到 prepay_id
3.1 请求参数怎么填才不会被拒
JSAPI 下单接口是POST /v3/pay/transactions/jsapi,核心字段有appid、mchid、description、out_trade_no、notify_url、amount、payer。其中amount.total单位是分,不是元,这个坑每年都有人踩。out_trade_no是商户订单号,同一单号重复下单会报错,退款也用这个号关联。payer.openid必须是当前 appid 下的用户 openid,跨公众号或小程序拿的 openid 用不了。
notify_url必须是公网可访问的 HTTPS 地址,不能带参数,微信会往这个地址 POST 加密后的通知。本地开发想调试,常见做法是用内网穿透工具映射一个临时域名,但要注意回调地址一旦配置就参与签名校验,改来改去容易乱。
3.2 组装请求并解析 prepay_id
<?php function jsapiOrder(WxPaySigner $signer, array $order): array { $urlPath = '/v3/pay/transactions/jsapi'; $body = json_encode([ 'appid' => $order['appid'], 'mchid' => $order['mchid'], 'description' => $order['desc'], 'out_trade_no'=> $order['out_trade_no'], 'notify_url' => $order['notify_url'], 'amount' => ['total' => $order['total'], 'currency' => 'CNY'], 'payer' => ['openid' => $order['openid']], ], JSON_UNESCAPED_UNICODE); $auth = $signer->buildAuthHeader('POST', $urlPath, $body); $ch = curl_init('https://api.mch.weixin.qq.com' . $urlPath); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: ' . $auth, 'Content-Type: application/json', 'Accept: application/json', ], ]); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { throw new RuntimeException('下单失败: ' . $resp); } return json_decode($resp, true); // 含 prepay_id }逻辑说明:json_encode必须带JSON_UNESCAPED_UNICODE,否则中文描述被转义后签名和实际发送的 body 不一致。参数说明:$order['total']传分,比如 1 元传 100;$order['desc']是商品描述,会显示在用户账单里,别写测试字样。拿到prepay_id后,前端还需要二次签名才能调起支付,这一步很多人漏掉,以为拿到 prepay_id 就完事。
3.3 前端调起支付需要的二次签名
prepay_id拿到后,要再拼一次签名给前端wx.chooseWXPay用:
<?php function buildPayParams(WxPaySigner $signer, string $appid, string $prepayId): array { $timestamp = time(); $nonce = bin2hex(random_bytes(16)); $pkg = 'prepay_id=' . $prepayId; // 注意这里拼串顺序和请求签名不同 $message = $appid . "\n" . $timestamp . "\n" . $nonce . "\n" . $pkg . "\n"; openssl_sign($message, $sig, $signer->getPrivateKey(), OPENSSL_ALGO_SHA256); return [ 'appId' => $appid, 'timeStamp' => (string)$timestamp, 'nonceStr' => $nonce, 'package' => $pkg, 'signType' => 'RSA', 'paySign' => base64_encode($sig), ]; }逻辑说明:调起支付的签名串是appid\n时间戳\n随机串\nprepay_id=xxx\n,和请求签名规则不同,别复用同一个方法。参数说明:timeStamp必须是字符串,前端有些框架传数字会报错;signType固定RSA。这一步签名失败,用户端会直接提示「支付参数错误」,但服务端日志里什么都看不到,属于典型黑匣子问题。
4. 回调验签与退款:钱进来和退回去的两个关键节点
4.1 支付回调验签和解密
微信支付结果通知是加密的,流程是:先验签确认来自微信,再用 APIv3 密钥 AES-256-GCM 解密resource字段。验签要用平台证书公钥,所以得先有平台证书。
<?php function verifyNotify(array $headers, string $body, string $platformCert, string $apiV3Key): array { // 1. 验签 $message = $headers['Wechatpay-Timestamp'] . "\n" . $headers['Wechatpay-Nonce'] . "\n" . $body . "\n"; $signature = base64_decode($headers['Wechatpay-Signature']); $pubKey = openssl_pkey_get_public($platformCert); $ok = openssl_verify($message, $signature, $pubKey, OPENSSL_ALGO_SHA256); if ($ok !== 1) { throw new RuntimeException('回调验签失败'); } // 2. 解密 resource $data = json_decode($body, true); $cipher = base64_decode($data['resource']['ciphertext']); $nonce = $data['resource']['nonce']; $aad = $data['resource']['associated_data']; $plain = openssl_decrypt( $cipher, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad ); return json_decode($plain, true); }逻辑说明:验签的拼串是「时间戳\n随机串\nbody\n」,顺序固定。参数说明:$platformCert是平台证书内容,不是路径;$apiV3Key是 32 位 APIv3 密钥,不是 API 密钥。解密时$tag从密文末尾取,openssl_decrypt的 GCM 模式会自动处理,但$aad必须传,否则解密失败。回调处理完必须返回{"code":"SUCCESS"},否则微信会持续重试。
4.2 退款申请接口
退款接口是POST /v3/refund/domestic/refunds,核心字段out_trade_no或transaction_id二选一,out_refund_no是退款单号,amount.refund是退款金额,amount.total是原订单金额。退款金额不能大于原订单金额,部分退款时total仍填原订单全额。
<?php function refund(WxPaySigner $signer, array $r): array { $urlPath = '/v3/refund/domestic/refunds'; $body = json_encode([ 'out_trade_no' => $r['out_trade_no'], 'out_refund_no' => $r['out_refund_no'], 'amount' => [ 'refund' => $r['refund'], // 本次退款,单位分 'total' => $r['total'], // 原订单总额,单位分 'currency' => 'CNY', ], 'notify_url' => $r['notify_url'], ], JSON_UNESCAPED_UNICODE); $auth = $signer->buildAuthHeader('POST', $urlPath, $body); // curl 发送逻辑同下单,此处省略 return ['auth' => $auth, 'body' => $body]; }逻辑说明:退款是异步的,接口返回SUCCESS只代表受理成功,不代表钱已到账。参数说明:out_refund_no必须唯一,重复提交同一退款单号微信会返回原结果,这是幂等设计,别用时间戳当单号。退款结果通过notify_url通知,通知内容同样要验签解密,字段里refund_status为SUCCESS才算真正退款成功。
4.3 退款结果通知的处理差异
退款通知和支付通知结构类似,但event_type是REFUND.SUCCESS或REFUND.ABNORMAL。处理时要注意:退款通知可能重复推送,业务侧必须用out_refund_no做幂等,更新退款单状态前先查一次。我一般把退款单状态机设计成「受理中 → 成功 / 失败」,收到通知只做状态流转,不重复发起退款。
5. 避坑与排查:这几个错误我替你踩过了
5.1 签名失败但报错信息很模糊
现象:接口返回401 Unauthorized或SIGN_ERROR,日志里看不出哪一步错。原因:签名串拼错,最常见的是 URL 路径漏了 query string,或者 body 被框架二次处理过(比如中间件改了 JSON 编码)。解决:把拼串的$message原样打日志,和官方文档的示例逐字符比对,重点看换行符是不是\n而不是\r\n,以及 body 是否和实际发送的完全一致。
5.2 回调一直重试,订单状态不更新
现象:微信后台显示回调失败,本地日志没有记录。原因:notify_url不可达,或者回调处理超时(微信要求 5 秒内响应),或者返回的不是标准 JSON。解决:先确认地址公网可访问,再把回调逻辑里耗时的操作(发消息、写大表)挪到异步队列,接口里只做验签、解密、更新订单状态,然后立刻返回{"code":"SUCCESS"}。
5.3 退款金额传成元导致多退
现象:退 1 元结果退了 100 元。原因:amount.refund单位是分,有人按元传了。解决:所有金额字段统一在入口处乘 100 并取整,数据库存分,展示时再除。这个错误一旦发生就是真金白银,建议在退款方法里加一道断言,refund > total直接抛异常。
5.4 平台证书过期导致验签全挂
现象:某天开始所有回调验签失败,但代码没动过。原因:平台证书轮换了,代码里用的是旧证书。解决:不要硬编码平台证书,用GET /v3/certificates定期拉取并缓存,验签时按Wechatpay-Serial头匹配对应证书。缓存建议设 12 小时过期,兼顾性能和及时性。
5.5 并发退款导致重复退款
现象:用户连点退款按钮,同一订单退了两次。原因:退款接口没有做幂等控制。解决:out_refund_no用订单号加固定后缀生成,保证同一订单同一退款请求单号一致;数据库对out_refund_no加唯一索引,插入失败直接返回已有退款结果。
6. 把退款状态查明白:主动查询与对账的收尾技巧
退款通知不是 100% 可靠,网络抖动或服务重启都可能丢通知,所以不能只依赖回调。我的习惯是加一个主动查询兜底:对超过 5 分钟还处于「受理中」的退款单,定时调GET /v3/refund/domestic/refunds/{out_refund_no}查真实状态。这个接口用 GET 请求,签名时 body 为空字符串,但 URL 路径要带退款单号。
<?php function queryRefund(WxPaySigner $signer, string $outRefundNo): array { $urlPath = '/v3/refund/domestic/refunds/' . $outRefundNo; // GET 请求 body 为空,但签名串最后一行仍要保留空行 $auth = $signer->buildAuthHeader('GET', $urlPath, ''); $ch = curl_init('https://api.mch.weixin.qq.com' . $urlPath); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: ' . $auth, 'Accept: application/json'], ]); $resp = curl_exec($ch); curl_close($ch); return json_decode($resp, true); }逻辑说明:GET 请求签名时$body传空字符串,拼串最后一行是空行,这个细节漏了会签名失败。参数说明:返回里的status字段是权威状态,SUCCESS表示退款成功,PROCESSING继续等,ABNORMAL需要人工介入。查询频率别太高,我一般 5 分钟一次,最多查 6 次,超过就告警人工处理。
对账是最后一道防线。每天定时下载微信账单,和本地订单、退款单逐笔比对,重点看金额和状态是否一致。账单文件是 CSV,用 PHP 的fgetcsv逐行读,注意账单里的金额单位是元,和接口的分不一样,比对前要统一。我踩过一次坑:账单里退款是负数,本地存的是正数,直接比对全部对不上,后来在比对逻辑里对退款取绝对值才通过。
这套东西值不值得做,我的判断是:只要你的业务涉及真实资金流转,自己封装一套支付退款类就是必须的,第三方聚合支付虽然省事,但费率和到账周期是长期成本。落地顺序建议先跑通 JSAPI 下单和回调,再加退款,最后补主动查询和对账。我自己的习惯是每接一个新商户号,先用 1 分钱真实支付走一遍全链路,确认回调、退款、查询都正常再上量。希望帮到你。
本文还有配套的精品资源,点击获取