简介:这是一份面向PHP开发者的微信支付V3接口接入案例,适合具备PHP面向对象基础、需要为项目快速集成微信支付能力的开发者。资源围绕V3版本API展开,同时兼容V2部分功能,覆盖订单创建、支付验证、退款申请、支付结果异步通知、支付状态查询等核心环节,并涉及OAuth2.0授权获取OpenID、RSA2048证书加解密与安全规范等关键知识点。压缩包共15个文件,以14个PHP源码文件与1个Markdown说明文档为主,PHP文件分别承担配置、核心支付类、JSAPI与Native扫码支付、订单与退款操作、回调通知及日志记录等职责,整体约42KB,结构紧凑便于按模块查阅。目前已有2012人学习下载。通过阅读与调试这些封装好的类,读者可掌握从预支付下单、前端唤起支付到回调验证、退款查询的完整链路,并借鉴其中的错误处理与日志调试思路,降低接入微信支付的复杂度。
1. 从一次支付回调翻车说起:PHP 微信支付V3 案例能帮你省掉哪些事
去年帮一个做知识付费的朋友排查线上问题,用户付了钱,后台订单还是待支付,客服手动补单补到崩溃。翻日志发现是回调验签失败,用的还是 V2 那套 MD5 拼串逻辑,而商户号早就升级到 V3 了。这个 PHP 微信支付V3 案例,就是冲着这类场景来的——它把 V3 接口的签名、验签、回调、退款、查单整条链路用 PHP 完整跑通,代码直接能跑,不是伪代码。适合两类人:一类是接支付接得少、每次都被证书和签名绕晕的后端;另一类是想把支付模块从老项目里剥出来、换成 V3 的维护者。下面按「能干什么 → 怎么跑 → 怎么改」拆开讲。
2. 项目定位与技术选型:为什么是 PHP + V3 而不是继续用 V2
2.1 这个案例到底封装了哪些支付能力
从标题和常见实现来看,这个案例覆盖的是微信支付 V3 的核心交易链路,不是只给一个「统一下单」的 demo。它一般会包含这几块:JSAPI 下单(公众号/小程序内支付)、Native 下单(扫码支付)、订单查询、申请退款、退款查询,以及最容易被忽略的回调通知处理。V3 和 V2 最大的区别在于:V2 用 MD5/HMAC-SHA256 拼参数签名,V3 改用 SHA256-RSA 对「方法 + URL + 时间戳 + 随机串 + 请求体」拼成的签名串做私钥签名,平台返回和回调则要用平台证书公钥验签。这个案例的价值就在于把这套非对称加密流程用 PHP 的 openssl 扩展落地了,而不是让你自己去啃文档里那段签名规则。
2.2 技术栈与运行环境
| 项目 | 说明 |
|---|---|
| 语言 | PHP 7.4+(V3 用到部分类型特性,7.4 起步比较稳) |
| 依赖 | 官方 wechatpay-php SDK 或自行封装的 openssl 签名类 |
| 扩展 | openssl(必须)、curl、json |
| 证书 | 商户 API 私钥 apiclient_key.pem、商户证书序列号、APIv3 密钥 |
| 运行方式 | 原生 PHP 起服务或挂到已有框架里 |
常见做法是直接引入官方wechatpay/wechatpay这个 composer 包,它把签名器、验签器、平台证书下载都封装好了。但很多老项目不方便上 composer,所以这个案例里通常也会给一份纯 openssl 手写的签名实现,方便你直接抄进老代码。选 PHP 而不是换语言,是因为存量项目里 PHP 占比高,支付这种和订单强耦合的模块,重写成本远大于原地升级。
2.3 目录结构长什么样
一个典型的案例目录大致是这样组织的:
wechat-pay-v3/ ├── config/ │ └── wxpay.php # 商户号、序列号、密钥路径等配置 ├── cert/ │ ├── apiclient_key.pem # 商户私钥 │ └── wechatpay_cert.pem # 平台证书(用于验签) ├── src/ │ ├── Signer.php # 签名与验签核心类 │ ├── Http.php # 带签名的请求封装 │ └── Pay.php # 下单/退款/查单业务方法 ├── notify.php # 回调通知入口 └── demo.php # 各接口调用示例这个结构的好处是签名逻辑和业务逻辑分开,Signer.php只干签名验签,Pay.php只拼业务参数。你二次开发时改业务只动Pay.php,换证书只动config和cert,互不干扰。很多新手把签名和下单写在一个文件里,后面加个退款就得复制一遍签名代码,维护起来是灾难。
3. 核心模块拆解:签名、下单、回调三段代码逐段看
3.1 签名与验签:V3 最容易翻车的地方
V3 的请求签名串由五行拼成,每行以\n结尾,最后一行也要带换行。这是最容易被忽略的细节,少一个换行签名就过不了。
<?php // Signer.php 核心签名方法 class Signer { // 构造签名串:方法\nURL\n时间戳\n随机串\n请求体\n public function buildMessage(string $method, string $url, int $timestamp, string $nonce, string $body): string { return $method . "\n" . $url . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; // 末尾这个换行不能省 } // 用商户私钥做 SHA256-RSA 签名 public function sign(string $message, string $privateKeyPath): string { $key = openssl_pkey_get_private(file_get_contents($privateKeyPath)); openssl_sign($message, $signature, $key, OPENSSL_ALGO_SHA256); return base64_encode($signature); // 请求头里要 base64 } // 用平台证书公钥验签(回调/应答都用它) public function verify(string $message, string $signature, string $platformCertPath): bool { $cert = openssl_x509_read(file_get_contents($platformCertPath)); $pubKey = openssl_pkey_get_public($cert); return openssl_verify($message, base64_decode($signature), $pubKey, OPENSSL_ALGO_SHA256) === 1; } }逻辑说明:buildMessage严格按官方规则拼五行,$body对 GET 请求传空字符串,但换行符依然要保留。sign用商户私钥签名,结果 base64 后放进Authorization头。verify用于回调,注意验签用的签名串是「时间戳\n随机串\n回调body\n」,和请求签名串的构造规则不同,这是第二个高频翻车点。参数上,$privateKeyPath指向apiclient_key.pem,$platformCertPath指向从微信下载的平台证书,两者不能混用。
3.2 统一下单:拼参数与发请求
<?php // Pay.php 里 JSAPI 下单示例 public function jsapiOrder(string $openid, int $totalFee, string $outTradeNo, string $desc) { $url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi'; $body = json_encode([ 'appid' => $this->config['appid'], 'mchid' => $this->config['mchid'], 'description' => $desc, 'out_trade_no' => $outTradeNo, 'notify_url' => $this->config['notify_url'], 'amount' => ['total' => $totalFee, 'currency' => 'CNY'], 'payer' => ['openid' => $openid], ], JSON_UNESCAPED_UNICODE); $timestamp = time(); $nonce = bin2hex(random_bytes(16)); $message = $this->signer->buildMessage('POST', '/v3/pay/transactions/jsapi', $timestamp, $nonce, $body); $signature = $this->signer->sign($message, $this->config['private_key']); // 组装 Authorization 头 $auth = sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"', $this->config['mchid'], $nonce, $signature, $timestamp, $this->config['serial_no'] ); // 后续用 curl 带 Authorization 头请求 $url,返回 prepay_id }逻辑说明:total单位是分,传 1 元要写 100,这是新手最常见的金额错误。out_trade_no是商户侧订单号,必须唯一,重复下单会报错。签名串里的 URL 只取路径部分(/v3/pay/transactions/jsapi),不带域名,带域名签名必挂。拿到prepay_id后还要用商户私钥再签一次,生成前端wx.requestPayment需要的paySign,这一步和请求签名是两码事,别搞混。
3.3 回调通知:验签、解密、幂等三件事
<?php // notify.php 回调处理骨架 $raw = file_get_contents('php://input'); $headers = getallheaders(); $timestamp = $headers['Wechatpay-Timestamp']; $nonce = $headers['Wechatpay-Nonce']; $signature = $headers['Wechatpay-Signature']; // 1. 验签:签名串是 时间戳\n随机串\nbody\n $message = $timestamp . "\n" . $nonce . "\n" . $raw . "\n"; if (!$signer->verify($message, $signature, $platformCertPath)) { http_response_code(401); exit('sign fail'); } // 2. 解密 resource 里的密文(AES-256-GCM) $data = json_decode($raw, true); $plain = $aesGcm->decrypt($data['resource']['ciphertext'], $data['resource']['nonce'], $data['resource']['associated_data']); // 3. 幂等:同一笔订单只处理一次 $order = json_decode($plain, true); if ($this->isProcessed($order['out_trade_no'])) { exit('success'); // 已处理也要回 success,否则微信会重推 } $this->markPaid($order['out_trade_no']); echo json_encode(['code' => 'SUCCESS']);逻辑说明:回调验签的签名串和请求签名串构造规则不同,这里是「时间戳\n随机串\nbody\n」,没有方法、URL 和请求体那几行。解密用 APIv3 密钥做 AES-256-GCM,associated_data和nonce都从回调体里取。幂等这步很多人不做,结果微信重推时重复发货,血泪经验。处理成功必须返回{"code":"SUCCESS"},返回别的微信会按策略重推,别想着返回个 200 空 body 糊弄过去。
4. 跑起来:从配置证书到本地验证的完整步骤
4.1 准备三样东西:私钥、序列号、APIv3 密钥
在商户平台申请 API 证书后,你会拿到apiclient_key.pem(商户私钥)和apiclient_cert.pem(商户证书),证书序列号在证书详情里能看到。APIv3 密钥是单独在「账户中心-API安全」里设置的 32 位字符串,用于解密回调,和签名私钥不是一回事。这三样缺一不可,很多人只配了私钥忘了 APIv3 密钥,结果回调解密直接报错。平台证书不用手动下载,SDK 或案例里的下载逻辑会自动拉取并缓存,但纯手写实现的话需要自己调/v3/certificates接口下载。
4.2 配置文件怎么填
<?php // config/wxpay.php return [ 'appid' => '你的公众号或小程序appid', 'mchid' => '你的商户号', 'serial_no' => '商户证书序列号', 'private_key' => __DIR__ . '/../cert/apiclient_key.pem', 'apiv3_key' => '你的32位APIv3密钥', 'notify_url' => 'https://你的域名/notify.php', ];notify_url必须是公网可访问的 https 地址,本地开发可以用内网穿透工具映射一个临时域名,但注意回调地址要和下单时传的一致。serial_no是商户证书序列号,不是平台证书序列号,这两个在验签和签名时分别用到,填反了签名能过但验签会挂。
4.3 本地跑通下单与回调
先起一个本地服务:php -S 0.0.0.0:8000,然后访问demo.php触发下单。JSAPI 下单需要真实 openid,本地测试建议先用 Native 下单,它会返回一个二维码链接,用微信扫码就能付。支付完成后微信会回调你的notify.php,本地没公网地址的话,可以先用案例里带的「手动触发回调」脚本,把一段模拟回调报文喂给notify.php,验证验签和解密逻辑是否跑通。这一步能过,说明证书和密钥配置没问题,再上真实环境就稳了。
提示:Native 下单返回的
code_url用在线二维码工具转成图片就能扫,测试阶段比 JSAPI 省事,不用折腾 openid 获取。
5. 避坑与排查:支付对接里那些反复出现的坑
5.1 签名失败:99% 是签名串拼错了
现象是接口返回 401 或「签名错误」。原因通常是签名串末尾少了换行、URL 带了域名、或者 GET 请求的 body 传了 null 而不是空字符串。解决方法是把拼好的签名串打印出来,逐行对照官方文档,重点看每行结尾是不是都有\n,最后一行有没有。我一般会在签名方法里加一行日志,把$message原样输出,对比一次就能定位。
5.2 回调验签失败:平台证书过期或没更新
现象是回调一直验签不过,但下单正常。原因是平台证书有有效期,微信会定期轮换,你本地缓存的是旧证书。解决方法是实现证书自动更新逻辑,每次验签前检查证书序列号是否在最新列表里,不在就重新下载。案例里如果没带这个逻辑,建议自己补一个定时任务,每天拉一次/v3/certificates。
5.3 金额对不上:单位是分不是元
现象是用户付了 1 元,订单记成 100 元,或者反过来。原因是 V3 的amount.total单位是分,整数。解决方法是下单前统一做一次intval($yuan * 100),回调里拿到的也是分,入库前想清楚存分还是存元,别两处不一致。这个坑我见过不止一个项目踩,退款时金额算错更麻烦。
5.4 重复回调导致重复发货
现象是用户收到两笔发货通知。原因是微信在没收到 success 应答时会重推,而你的处理逻辑没有幂等。解决方法是用out_trade_no做唯一索引,处理前先查状态,已处理直接返回 success。别用「先查再写」这种有并发窗口的方式,直接靠数据库唯一约束兜底最稳。
5.5 证书路径写错导致 openssl 报错
现象是openssl_sign(): supplied key param cannot be coerced。原因是私钥路径不对,或者文件内容被编辑器加了 BOM 头。解决方法是确认file_get_contents能读到内容,且文件开头是-----BEGIN PRIVATE KEY-----,没有多余字符。用绝对路径比相对路径靠谱,相对路径在不同入口文件下解析结果不一样。
6. 二次开发:把案例改造成能进生产环境的支付模块
案例能跑通不代表能直接上生产,中间还差几块。第一块是配置外置,别把商户号和密钥硬编码在代码里,挪到环境变量或配置中心,config/wxpay.php里读getenv('WX_MCHID')这种。第二块是日志,支付出问题没有日志就是黑匣子,建议在签名、请求、回调三个节点各打一条日志,记录out_trade_no、请求参数、返回码,但注意别把私钥和 APIv3 密钥打进日志。第三块是异常处理,网络超时、微信返回错误码都要捕获,下单失败要能回滚本地订单状态,不能用户付了钱订单还是待支付。
改造时我一般会抽一个PayService类,把下单、查单、退款、回调处理都收进去,对外只暴露业务方法,签名和 HTTP 请求藏在内部。这样换 SDK 或者升级接口版本时,只动PayService内部,调用方无感。验证改造是否成功,我会写一个最小回归脚本:Native 下单 → 扫码支付 → 回调入库 → 查单确认 → 发起退款 → 退款回调,整条链路跑一遍,每步断言状态。这个脚本跑通,基本就能上生产了。
还有一个具体技巧:把平台证书的下载和缓存单独封装,缓存到本地文件并记录序列号和过期时间,验签时先读缓存,过期或序列号不匹配再下载。这样既避免每次回调都请求微信接口,又不会因为证书轮换导致验签失败。代码上就是给Signer加一个getPlatformCert()方法,内部判断缓存有效性。
从那以后我每次接支付,都强制先把「签名串打印 + 回调幂等 + 金额单位」这三件事过一遍,再动手写业务。这三样不出问题,支付模块基本就稳了。希望帮到你。
本文还有配套的精品资源,点击获取