
简介一套基于PHP微信支付V3的完整接入实例面向需要为商城、小程序或线下收银场景快速集成微信支付的PHP开发者重点解决统一下单、回调验签、证书配置、订单查询与退款申请等实际开发难点。压缩包共16个文件体积仅61KB主要由ASP后端页面、PHP接口脚本、PEM证书、MDB数据库、JS脚本、说明文档及加载图组成兼顾服务端逻辑与前端展示可对照完整流程进行本地联调。目前已有4621人学习下载。内容不仅梳理了V3版本的API签名与证书管理机制还给出统一下单、支付回调、沙箱测试、异常处理和退款接口的实现思路目录按demo演示、证书、公共类库和数据等模块划分便于快速定位代码。对于希望在PHP项目中落地微信支付V3的开发者来说这份实例能帮助绕过常见配置陷阱短时间搭建一套可运行的支付环境。1. PHP微信支付v3以为是换接口其实是换签名体系PHP 微信支付接口从 v2 升到 v3很多 PHPer 的第一反应是照着旧代码换几个 URL结果统一下单第一步就卡在“提示用户态签名signature错误”上。我接过三个跑在 PHP 7.x 的存量项目v3 真正的门槛不在接口地址而在三件事请求头要用商户私钥做 SHA256-RSA2048 签名回调通知要用 APIv3 密钥做 AES-256-GCM 解密微信支付平台证书还要定期更新并参与验签。这三件事搞明白不依赖官方 SDK半小时也能把 JSAPI 支付和退款调通。这篇笔记适合已经跑通过 v2 想迁到 v3 的开发者也适合第一次接 v3、不想把签名逻辑当黑匣子的人。2. 证书与密钥v3 的四个凭证放错一个就是连环签名错误先理清 v2 和 v3 的凭证差异。v2 时代核心是一个 API 密钥加 MD5 签名一个 key 走天下v3 改成四件套各自负责一段签名算法也从 MD5 换成了 SHA256-RSA2048。搜资料时经常碰到“php hmacsha256”的旧文章那是 v2 的另一种签名方式v3 里用不到别混在一起看。2.1 四样凭证分别做什么凭证文件或形式用途更换与注意商户 API 私钥apiclient_key.pem生成请求签名、小程序调起支付 paySign商户平台生成泄露要立即吊销商户证书序列号serial_no 字符串告诉微信端“我是哪张商户证书”随商户证书一起换APIv3 密钥32 位字符串解密回调 resource、解密平台证书下载接口密文商户平台独立设置与 v2 密钥不通用微信支付平台证书wechatpay_xxx.pem验签响应、验签回调、加密敏感字段有效期约 5 年需定期刷新这四样里最容易搞混的是商户证书序列号和平台证书序列号。商户证书序列号在商户平台“API安全→API证书”里能看到回包验签时从头部的 Wechatpay-Serial 拿到的则是平台证书序列号两个都叫 serial位置完全不一样。我在项目里见过有人把平台证书序列号填进 Authorization 头结果微信直接回“商户API证书序列号错误”。2.2 一个 PayConfig 类把配置收拢?php class PayConfig { public string $appId; public string $mchId; // 商户号 public string $serialNo; // 商户证书序列号不是平台证书序列号 public string $privateKey; // 商户 API 私钥内容注意保留换行 public string $apiV3Key; // 32 位 APIv3 密钥 public string $notifyUrl; // https 回调地址 public function __construct(array $config) { foreach ($config as $k $v) { $this-$k $v; } // 私钥建议读文件不要直接写在代码里 if (isset($config[private_key_path])) { $this-privateKey file_get_contents($config[private_key_path]); } } }逻辑说明这种“配置即对象”的写法是把后面签名、解密、请求都要用的六个值收拢到一个类里避免每个接口文件各写一套配置。privateKey 我建议从 pem 文件读取而不是把内容粘进数组因为 pem 自带换行手动复制经常丢换行openssl_pkey_get_private 一加载就返回 false而且这种报错往往到签名那一步才暴露。参数说明serialNo 只填商户证书序列号apiV3Key 是商户平台里单独设置的 32 位密钥别拿 v2 的 API 密钥顶替两个在商户平台是两个独立入口。notify_url 必须公网可访问的 https 地址而且域名要和商户平台配置的支付目录同主体否则回调收不到。2.3 平台证书下载一次按序列号管理微信支付平台证书有三个来源商户平台直接下载 pem、调用/v3/certificates接口下载、官方 SDK 内置的证书管理器自动刷新。裸写 PHP 时最简单的是去商户平台“API安全→平台证书”手工下载但证书五年一换手工文件容易过期。常见做法是写一个刷新脚本定期拉/v3/certificates把返回的 resource 用 APIv3 密钥解密后落盘。这个下载接口的加密方式和回调完全一样都是 AES-256-GCM正好复用第 4 章的解密函数。落盘文件名我一般直接用证书序列号cert/{serial}.pem。验签时从回调头或响应头拿到 Wechatpay-Serial再拼成路径读文件这样哪张证书过期一目了然避免“永远在读同一个固定文件”。这里有一个值得养成的习惯新证书下来后旧证书别急着删保留两个序列号的文件。微信在证书切换前后的一段时间内两类证书都可能出现只删旧证书会让验签在切换窗口期抽风。3. 手写 SHA256-RSA2048 签名统一下单从 Authorization 头开始v3 的所有业务请求都要带 Authorization 头HTTP 头长这样Authorization: WECHATPAY2-SHA256-RSA2048 mchid1900000001,nonce_stra1b2c3d4,signaturebase64串,timestamp1710000000,serial_no1A2B3C要组装出这个头得先按规则拼一段待签名串再用商户私钥做 RSA 签名。下面拆开讲。3.1 签名串构造规则位置取值注意HTTP 方法POST / GET / PUT全大写URL/v3/pay/transactions/jsapi路径加查询串不含域名时间戳time()与微信服务器偏差不能超过 5 分钟随机串bin2hex(random_bytes(16))每次请求重新生成别用 uniqid请求体json 字符串GET 为空串但换行仍在待签名串按“方法、URL、时间戳、随机串、请求体”的顺序用换行拼接末尾还要再补一个换行五个部分共四个分隔换行加一个结尾换行。以统一下单为例明文是POST\n/v3/pay/transactions/jsapi\n1710000000\na1b2c3d4\n{appid:wx...}\n最常见的翻车点是请求体签名用的 body 必须和 curl 真正发出去的 body 字节一致。json_encode 默认会把中文转成\uXXXX把/转成\/一旦你签名时用原始中文、请求时用转义后的字符串两端就错位了。我的固定写法是先$body json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);签名和请求都用同一个$body。3.2 签名函数与请求头组装private function sign(string $method, string $urlPath, string $body, int $timestamp, string $nonce): string { $message $method . \n . $urlPath . \n . $timestamp . \n . $nonce . \n . $body . \n; openssl_sign($message, $signature, $this-config-privateKey, sha256WithRSA); return base64_encode($signature); }逻辑说明openssl_sign 的第四个参数传sha256WithRSA对应微信要求的 SHA256-RSA2048。函数输出的是二进制签名必须 base64 编码后才能放进 Authorization。这里我不会用 openssl_pkey_sign因为它默认走低级 API参数和返回值都不直观封装一层更省事。参数说明urlPath 只放路径部分带查询参数的接口要把 query 原样拼进去例如查单接口/v3/pay/transactions/out-trade-no/20250101001?mchid1900000001。timestamp 用服务端 time() 即可重点保证服务器时间本身是准的。拿到签名后拼 Authorization再发起 curl$timestamp time(); $nonce bin2hex(random_bytes(16)); $authorization sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%d,serial_no%s, $config-mchId, $nonce, $this-sign(POST, $urlPath, $body, $timestamp, $nonce), $timestamp, $config-serialNo ); $ch curl_init(https://api.mch.weixin.qq.com . $urlPath); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_HTTPHEADER, [ Authorization: . $authorization, Accept: application/json, Content-Type: application/json, User-Agent: php-pay-demo/1.0, ]); $response curl_exec($ch);逻辑说明curl 的请求头必须带 Accept 和 Content-Type 为 application/jsonUser-Agent 也不要留空微信网关对缺失 UA 的请求会直接拒绝。POSTFIELDS 传的是字符串而不是数组字符串才能保证和签名用的 body 完全一致一旦传数组curl 会自己编码一次签名必挂。3.3 统一下单与小程序调起支付JSAPI 下单的 body 字段里appid 是小程序或公众号的 appidmchid 是商户号三者必须在商户平台完成绑定。amount.total 单位是分别传成元这是和金额相关的第一坑。下单成功返回 prepay_id但小程序要真正弹起收银台还得再算一次 paySign。$params [ appId $config-appId, timeStamp (string) time(), nonceStr bin2hex(random_bytes(16)), package prepay_id . $prepayId, signType RSA, ]; // paySign 的签名串是 appId、timeStamp、nonceStr、package 四段末尾补换行 $message $params[appId] . \n . $params[timeStamp] . \n . $params[nonceStr] . \n . $params[package] . \n; // 复用 3.2 的 openssl_sign 逻辑对 $message 签名再 base64 作为 paySign $params[paySign] base64_encode($this-signRaw($message));逻辑说明paySign 的签名串是 appId、timeStamp、nonceStr、package 四段用换行连接末尾同样补一个换行签名算法和上面完全一致。实际编码时可以把“拼串”和“签名”拆成两个方法sign 负责拼 v3 请求串signRaw 负责裸签名两种场景共用 openssl_sign 那一层。这里的随机串可以重新生成不需要和下单时的 nonce 相同。App 支付、H5 支付、小程序支付在签名层面共用同一套 SHA256-RSA2048区别只在接口和调起参数的组织方式所以签名函数写一次全端复用。常见错误是丢package里的prepay_id前缀前端拿到后 wx.requestPayment 一直报“支付签名验证失败”。排查时先把前端收到的原始参数打出来看 package 是否长这样prepay_idwx111222333等号丢了就是后端拼错了。4. 回调解密与验签AES-256-GCM 是 v3 回调的核心v3 的回调和 v2 最大的不同是业务字段全部装在 resource 里加密传输。外层结构里 algorithm 固定是 aes-256-gcmciphertext 是密文nonce 是初始向量associated_data 是附加认证数据。这四个字段哪个用错解密就是 false。4.1 先验签再解密回调请求头里带着 Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial 四个字段。验签用的签名串是“时间戳、随机串、原始请求体”三段用换行连接再加结尾换行。验签通过后才解密 resource顺序反过来的话密文被篡改时解密也会失败但你看不出是传输被改还是密钥错了。验签用哪张证书由 Wechatpay-Serial 决定。这个序列号对应微信支付平台证书不是商户证书更不是 APIv3 密钥。我见过有人在验签时把商户 apiclient_cert.pem 传进去openssl_verify 也会偶尔成功但频率大概只有一半纯属玄学别这么干。取回调头的时候注意PHP 的$_SERVER会把回调头转成全大写并以HTTP_开头比如HTTP_WECHATPAY_SERIAL。调用验签函数前先统一把头转成键值数组避免大小写不一致导致取不到值。4.2 PHP 验签与解密实现public function handleNotify(array $headers, string $body): array { // 1. 从回调头拿微信证书序列号定位对应平台证书 $serial $headers[Wechatpay-Serial]; $certContent file_get_contents(__DIR__ . /cert/{$serial}.pem); $pubKey openssl_pkey_get_public($certContent); // 2. 验签签名串 时间戳 换行 随机串 换行 raw body 换行 $message $headers[Wechatpay-Timestamp] . \n . $headers[Wechatpay-Nonce] . \n . $body . \n; $verifyOk openssl_verify($message, base64_decode($headers[Wechatpay-Signature]), $pubKey, sha256WithRSA); if ($verifyOk ! 1) { throw new RuntimeException(notify verify failed); } // 3. 解密 resourceciphertext 尾部 16 字节是 GCM tag $resource json_decode($body, true)[resource]; $ciphertext base64_decode($resource[ciphertext]); $tag substr($ciphertext, -16); $ciphertext substr($ciphertext, 0, -16); $plaintext openssl_decrypt( $ciphertext, aes-256-gcm, $this-config-apiV3Key, OPENSSL_RAW_DATA, $resource[nonce], $tag, $resource[associated_data] ?? ); return json_decode($plaintext, true); }逻辑说明openssl_verify 返回 1 表示验签通过0 表示不通过-1 表示证书或算法错误所以判断条件写成! 1只把严格成功当成通过。解密时密文尾部 16 字节是 AES-GCM 的认证标签必须先拆出来传给 openssl_decrypt 的第六个参数直接拿整个 ciphertext 去解密会返回 false 且不报任何错误日志里只能看到“解密失败”四个字。参数说明apiV3Key 必须是商户平台设置的 32 位密钥nonce 传 resource.nonce 原值associated_data 从 resource 里取如果空就传空字符串。PHP 要求 7.1 以上才支持 aes-256-gcm跑在 PHP 7.0 的老项目升级时要注意这个隐性门槛。4.3 回调应答与重试节奏处理成功要回 HTTP 200body 为{code:SUCCESS,message:成功}。业务失败时回非 200 或{code:FAIL,message:失败原因}微信会按 15 秒、15 秒、30 秒、3 分钟、10 分钟的节奏重试最久能重试好几个小时。所以回调里别做重活我一般只把支付结果落一张 pending 表然后立刻返回 SUCCESS真正的订单状态更新放到异步任务里避免回调超时造成重复通知。注意message 字段是给开发者看的别把堆栈信息或商户订单号塞进去长度过长微信可能按签名失败处理。返回体 Content-Type 设置为 application/json。5. 避坑指南签名错误、时间漂移、平台证书过期三连击v3 调不通九成问题集中在下面五个场景。每条都是我在项目里真实踩过的按“现象→原因→解决”写。5.1 “提示用户态签名signature错误”到底是谁的锅现象统一下单或查单请求返回“提示用户态签名signature错误”或者报“商户API证书序列号错误”。原因Authorization 里的 serial_no 填成了平台证书序列号或者从商户平台复制序列号时带进了空格、换行。两个 serial 长得很像一错就是这类报错。解决用命令输出商户证书的真实序列号以命令结果为准openssl x509 -in apiclient_cert.pem -noout -serial把输出里的十六进制串填进配置。另外确认私钥和证书是同一对混用旧私钥配新证书会得到“证书与私钥不匹配”。5.2 本地能通、服务器挂先对时间现象同一套代码本地调通部署到测试服务器后签名必失败。原因服务器系统时间与标准时间偏差超过 5 分钟微信网关校验时间戳时直接拒绝。另一种可能是服务器 PHP 的 openssl 扩展版本太老SHA256 支持不全。解决服务器上执行date看时间偏差大就配置 NTP 自动同步用php -m | grep openssl确认扩展存在openssl 库低于 1.1.1 的建议升级。排查签名问题时时间戳是第一个要排除的变量。5.3 回调解密出乱码tag 和 associated_data 各管各的现象回调收到验签通过但 openssl_decrypt 返回 false。原因三个细节按出现频率排——ciphertext 尾部的 tag 没拆associated_data 传了 null 而不是空字符串APIv3 密钥填成了 v2 的 API 密钥。第三点最常见很多老项目 v2 和 v3 的配置混在同一个文件里复制时拿错。解决按 4.2 的写法先把substr($ciphertext, -16)拆 tag再用 openssl_decrypt 第六参数传 tag第七参数传 associated_data。密钥从商户平台单独确认一次确认是那 32 位 APIv3 密钥。5.4 平台证书更新后验签突然失败现象某天开始日志里全是“unable to get local issuer certificate”所有回调验签失败而代码没动过。原因微信支付平台证书被替换本地仍在读旧 pem或者按固定文件名读证书新证书序列号变了但文件没更新。解决落盘文件名用{serial}.pem验签前从 Wechatpay-Serial 取序列号再拼路径不写死文件名刷新证书的定时任务跑完后保留旧文件切换窗口期两类证书都能验。这个习惯能省掉 90% 的证书类问题。5.5 钱扣了订单状态没更新现象用户已支付微信商户平台账单显示成功但业务订单还是“待支付”。原因回调收到了但处理超时或返回格式不对微信判定失败进入重试也可能回调节点根本没配置对notify_url 和下单时不一致。解决先看应用日志确认回调是否到达。处理逻辑改成“先落 pending 表立刻返回 SUCCESS异步更新订单”。同时按下单请求体里的 notify_url 和商户平台配置的地址两者必须完全一致。救急手段是主动查单这就是下一章要写的兜底方案。6. 从能付到敢上线主动查单兜底与订单幂等回调不一定可靠但主动查单一定能拿到最终状态。把查单和订单状态机做好线上丢单的概率能压到接近零。6.1 主动查单支付结果不只看回调用户在小程序里支付完成后关闭页面或者支付回调因为网络原因迟到主动查单都能兜住。查询接口路径是/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid{mchid}GET 请求同样要签名签名串里的 body 为空字符串但结尾换行仍然保留。$urlPath /v3/pay/transactions/out-trade-no/ . $outTradeNo . ?mchid . $config-mchId; $timestamp time(); $nonce bin2hex(random_bytes(16)); $authorization sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%d,serial_no%s, $config-mchId, $nonce, $this-sign(GET, $urlPath, , $timestamp, $nonce), $timestamp, $config-serialNo );响应里的 trade_state 是核心SUCCESS 表示支付成功REFUND 表示已退款NOTPAY 表示未支付CLOSED 表示订单关闭。我一般在支付成功跳转页调一次查单另外每五分钟扫一遍超时未支付的订单两个入口都能把订单状态纠正过来。查单响应同样带有 Wechatpay-* 签名头记得用平台证书验证后再信任 trade_state。6.2 幂等更新同一笔订单只入账一次有了查单兜底后同一笔订单可能被回调更新又被查单更新必须保证幂等。常见做法是用条件 UPDATE 限制状态流转UPDATE orders SET status PAID, paid_at NOW(), transaction_id :txid WHERE out_trade_no :out_trade_no AND status PENDING;影响行数为 1 才说明本次更新生效如果另一个入口已经把订单改成 PAID第二条语句的 WHERE 条件不满足影响行数为 0自然跳过不会重复入账。这个写法比先 SELECT 再 UPDATE 更稳省掉了锁和事务的复杂度。6.3 上线前最后五分钟的验证清单我每次接完 v3上线前都会把下面几步强制走一遍用一分钱真实支付跑通下单到回调全链路故意在回调里不返回 SUCCESS观察微信重试和最终订单状态把服务器时间调偏五分钟验证签名确实会失败再同步回来最后走一笔退款确认退款回调的解密字段同样用 APIv3 密钥。从那以后我每次接微信支付都会强制走一遍这套流程签名、解密、查单、幂等四个点全绿再合并代码这套习惯替我拦下过一起线上丢单事故希望帮到你。本文还有配套的精品资源点击获取