
简介这份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 证书序列号证书详情页放进 AuthorizationAPIv3 密钥商户平台设置回调解密微信平台证书通过接口下载回调验签平台证书不是固定文件它会轮换所以正确做法是用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_id3.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_idxxx\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 分钱真实支付走一遍全链路确认回调、退款、查询都正常再上量。希望帮到你。本文还有配套的精品资源点击获取