☰
微信支付v3完整PHP实例:签名验签、回调解密与证书自动刷新
2026/10/14 4:07:24 网站建设 项目流程

简介:面向PHP开发者的微信支付V3完整实例,系统梳理了从商户平台配置、API密钥与证书管理、统一下单、前端唤起支付、异步回调验签到订单查询、退款及异常处理的完整链路,并详解V3特有的API签名、参数加密、沙箱环境测试与安全合规要点,适合电商网站、线下商户系统或开发者个人项目快速接入最新微信支付时参考。压缩包共16个文件,以PHP支付核心代码为主,同时搭配ASP中转脚本、JavaScript库、pem证书、Access数据库及多份说明文档,目录结构一目了然,整体仅61KB,轻量易部署;demo与文件说明清晰展示各模块的调用关系,能帮助初学者避开证书配置、回调验签等常见坑。无论首次接入还是版本升级,这套实例都能提供可直接参考的代码和排错思路。已有4644人学习下载,对需要对照官方文档落地V3支付实操经验的技术人员极具参考价值。

1. 微信支付v3完整实例到底难在哪:先分清三把钥匙

不少团队从 V2 迁到 V3 时,第一个感觉是「签名怎么这么容易报错」。跟 V2 的 MD5 拼接相比,V3 要求用商户私钥做 RSA-SHA256,签名串里全是换行符,时间戳、随机串、证书序列号一个都不能错,错了接口只回一句「签名错误」,没有任何额外提示。很多人把这个当成网络层的玄学,实际 90% 的报错都能归结为钥匙拿错了:发请求得用商户私钥签名,验微信返回的响应得用微信支付平台证书的公钥,解密回调得用 APIv3 密钥。三把钥匙对应三段独立逻辑,本实例就把这三段完整串起来,给你一份从下单、拉起支付、回调验签解密到避坑自查的闭环 PHP 代码。适合正在做小程序或公众号支付、需要一个能直接落地的支付模块的开发者,也适合从 V2 迁 V3、想彻底搞懂签名机制的团队。

2. 支付模块初始化:PHP 环境、目录布局与参数清单

2.1 环境要求与项目结构

本实例用原生 PHP 实现,不依赖 Laravel、ThinkPHP 等框架,为的是把签名和验签的每一步都摊开给你看。PHP 版本建议 7.4 以上,实际生产我推荐 8.1,主要因为 8.0 以后 openssl 扩展对密钥对象的处理更顺手,类型声明也更严格。需要确认三个扩展都在:openssl、json、curl。可以先用一行命令检查:

php -m | grep -E "^(openssl|json|curl)$"

如果 curl 不在,多半是 PHP 安装时没启用;openssl 不在,签名直接没法做。另一个关键点是 PHP 8 下 openssl_sign 的私钥参数可以用 OpenSSLAsymmetricKey 对象,也可以直接传 PEM 字符串,下面代码两种都兼容,但千万别传成证书文件路径,那是很多新手翻车的地方。

项目结构按「一个支付模块独立成包」的思路组织,方便以后多个项目复用:

pay-demo/ ├── config/ │ └── pay.php # 商户参数和路径配置 ├── src/ │ ├── PaymentClient.php # 核心请求/签名/验签类 │ └── NotifyHandler.php # 回调处理类 ├── certs/ │ ├── apiclient_key.pem # 商户私钥(自己保管) │ ├── apiclient_cert.pem # 商户公钥证书 │ └── platform/ # 微信支付平台证书,程序自动写入 ├── example/ │ ├── order.php # 下单入口 │ └── notify.php # 回调入口 └── composer.json # 可选,用于自动加载

certs 目录记得加进 .gitignore,尤其是 apiclient_key.pem,任何情况下不能进仓库。platform 目录不需要手动放证书,后面会用接口自动拉取,这也是 V3 比 V2 更「现代化」的地方。

提示:如果项目里已经用 Composer 管理依赖,可以在 composer.json 里加wechatpay/wechatpay官方 SDK。本实例为了把过程讲透选择手写实现,生产环境直接用官方包也能跑通,核心原理和这里完全一致。

2.2 商户平台证书下载与关键参数对照表

参数配置是整个支付模块的地基。先从微信支付商户平台拿到三样东西:商户号、AppID、商户 API 证书。商户 API 证书在商户平台的「API 安全」里申请下载,下载后是一个包含 apiclient_cert.pem 和 apiclient_key.pem 的压缩包;APIv3 密钥需要自己在同一页面设置,要求 32 位可见字符,设置完不会再有第二次查看机会,丢了只能重置。最后把证书和密钥填进配置:

<?php // config/pay.php return [ // 商户号,商户平台「账户中心-商户信息」里查看 'mchid' => 'your-mchid', // 小程序或公众号的 AppID 'appid' => 'your-appid', // 商户 API 证书序列号,商户平台「API 安全」里查看 'serial_no' => 'YOUR_APICLIENT_SERIAL_NO', // 商户私钥文件路径,对应 apiclient_key.pem 'private_key_path' => __DIR__ . '/../certs/apiclient_key.pem', // 微信支付平台证书存放目录,程序首次启动后自动写入 'platform_cert_dir' => __DIR__ . '/../certs/platform/', // APIv3 密钥,商户平台设置的 32 位字符串 'api_v3_key' => 'your-32-api-v3-key', // 支付结果回调地址,必须公网可访问且是 HTTPS 'notify_url' => 'https://pay.example.com/notify.php', ];

这里有一个关键知识点必须拎出来:商户 API 证书序列号和微信支付平台证书序列号是两回事。Authorization 请求头里带的 serial_no 是你的商户 API 证书序列号,用来告诉微信「我是谁」;验签时用到的 Wechatpay-Serial 是微信支付平台证书序列号,用来决定「拿哪张平台证书的公钥验签」。如果你只在本地手动下载了所谓的微信支付平台证书,却没有和接口返回的序列号对上,验签必败。

提示:商户 API 证书序列号可以用 openssl 命令读取确认:

openssl x509 -in certs/apiclient_cert.pem -noout -serial

输出的十六进制去掉冒号、转成大写,就是应该写在配置里的 serial_no。常见错误是直接复制了证书文件名的那串数字,或者把平台证书的序列号写进来,这类问题通常在第一次请求时就会暴露。

3. 核心签名封装:请求签名、响应验签与平台证书自动刷新

3.1 构造 Authorization 请求头

微信支付 V3 的请求签名并不复杂,但格式极其严格。签名串由五个部分组成,中间用\n连接:请求方法、请求路径(带查询参数)、时间戳、随机字符串、请求体原文。请求体必须是实际发送的那串 JSON,不能是格式化后的,也不能是数组序列化后的其他形式。所以先用 json_encode 得到 bodyJson,再用它同时做签名和发送,两边保持一致是签字成功的前提。

下面这段是核心签名方法,我一般直接放在 PaymentClient 类里:

<?php // src/PaymentClient.php 部分代码 public function buildAuthHeader(string $method, string $urlPath, string $body = ''): array { $timestamp = time(); $nonce = bin2hex(random_bytes(16)); // 换行符一个不能少,顺序也不能换 $signStr = $method . "\n" . $urlPath . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; openssl_sign($signStr, $signature, $this->privateKey, OPENSSL_ALGO_SHA256); $auth = sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"', $this->config['mchid'], $nonce, $timestamp, $this->config['serial_no'], base64_encode($signature) ); return [ 'Authorization' => $auth, 'User-Agent' => 'Mozilla/5.0 ' . $this->config['mchid'], 'Accept' => 'application/json', 'Content-Type' => 'application/json', ]; }

参数说明:timestamp 必须是当前 Unix 时间戳,微信会校验偏差,超过五分钟会拒绝;nonce 是每次请求独立的随机字符串,不能复用;serial_no 是商户 API 证书序列号,不是平台证书序列号。openssl_sign 的结果是二进制签名,必须 base64 编码后再放进 Authorization 头。如果签名报错,先检查$signStr里是不是每一段后面都有换行符,尤其是最后那个 body 后面的\n,少一个整个签名就废了。

3.2 POST 请求封装:发送、读取、验签一条龙

签完名之后,真正的请求发送也没那么简单。因为响应体也是需要验签的,所以 curl 的响应头不能丢。我习惯写一个统一的 request 方法,把请求、响应头解析、响应验签全部收拢在一起,业务代码只需要关心返回的数组:

public function request(string $method, string $urlPath, array $body = [], bool $skipVerify = false): array { $baseUri = 'https://api.mch.weixin.qq.com'; $bodyJson = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $headers = $this->buildAuthHeader($method, $urlPath, $bodyJson ?: ''); $ch = curl_init($baseUri . $urlPath); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $this->formatHeaders($headers)); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); if ($bodyJson) { curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson); } curl_setopt($ch, CURLOPT_HEADER, true); [$responseHeader, $responseBody] = $this->execute($ch); if (!$skipVerify && !$this->verifyResponse($responseHeader, $responseBody)) { throw new RuntimeException('微信支付响应验签失败'); } return json_decode($responseBody, true); }

这里必须提一下 JSON_UNESCAPED_UNICODE 和 JSON_UNESCAPED_SLASHES 两个常量。不开启的话,JSON 里中文会被转成\uXXXX,URL 里的/会变成\/,虽然语义相同,但字节串和签名时不一致,微信那边会验签失败。字符串编码问题排在证书问题之后,是签名失败的第三大原因。

响应验签也放在同一个类里:

public function verifyResponse(array $headers, string $body): bool { $timestamp = $headers['wechatpay-timestamp']; $nonce = $headers['wechatpay-nonce']; $serial = $headers['wechatpay-serial']; $signature = base64_decode($headers['wechatpay-signature']); // 验签串和请求签名串不同:只有时间戳、随机串、响应体 $message = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; $pem = $this->loadPlatformCert($serial); if (!$pem) { $this->refreshPlatformCerts(); $pem = $this->loadPlatformCert($serial); } $publicKey = openssl_pkey_get_public($pem); return 1 === openssl_verify($message, $signature, $publicKey, OPENSSL_ALGO_SHA256); }

响应验签和请求签名最大的区别就是验签串不带请求方法和路径,只带时间戳、随机串和响应体。loadPlatformCert 按证书序列号去本地目录找对应的 PEM 文件;找不到时主动触发一次证书刷新,这是应对微信平台证书轮换的关键机制,后面第 6 章还会展开。

3.3 平台证书下载与自动轮换

微信支付平台证书会不定期轮换,手工下载根本跟不上节奏。V3 提供了官方下载接口:GET /v3/certificates,返回的证书是加密的,需要用 APIv3 密钥配合 AES-256-GCM 解密,再按序列号存到本地。注意这个接口本身也要验签响应,但这里存在一个鸡生蛋的问题:下载证书的响应验签需要证书,而证书还没下载。所以刚才的 request 方法里留了一个$skipVerify参数,下载证书时跳过响应验签,解密后就地验一把 PEM 是否合法。

public function refreshPlatformCerts(): void { $result = $this->request('GET', '/v3/certificates', [], true); foreach ($result['data'] ?? [] as $item) { $plainPem = $this->decryptResource($item['encrypt_certificate']); file_put_contents( $this->platformCertPath($item['serial_no']), $plainPem ); } } public function decryptResource(array $encrypted): string { $ciphertext = base64_decode($encrypted['ciphertext']); $nonce = $encrypted['nonce']; $aad = $encrypted['associated_data'] ?? ''; // AES-256-GCM 的 tag 在密文最后 16 字节,必须先拆分 $tag = substr($ciphertext, -16); $ciphertext = substr($ciphertext, 0, -16); $plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', $this->config['api_v3_key'], OPENSSL_RAW_DATA, $nonce, $tag, $aad ); return $plaintext; }

这个 decryptResource 方法非常关键,它会被两处复用:一处是这里的平台证书解密,另一处是后面的支付回调解密。AES-256-GCM 模式里,tag 是完整性校验值,必须从密文尾部拆出来,然后作为 openssl_decrypt 的第七个参数传入。如果直接把整段 base64 解码后的密文当成 tag 传进去,openssl_decrypt 会直接返回 false,报错里看不出任何有效信息,这是全流程里最隐蔽的坑。

4. JSAPI 下单到前端拉起支付:最小完整链路

4.1 构造统一下单请求并拿到 prepay_id

小程序和公众号支付,V3 对应的下单接口是POST /v3/pay/transactions/jsapi。请求体里最重要的几个字段是 appid、mchid、description、out_trade_no、notify_url、amount 和 payer。注意 amount.total 单位是分,不是元,整数类型,别在某个深夜把 1000 当成 10 元传上去。payer.openid 必须是在该小程序或公众号环境下授权的用户 openid,跨平台拿到的 openid 一律无效。

public function createJsapiOrder(string $openid, string $outTradeNo, int $amount, string $description): array { $body = [ 'appid' => $this->config['appid'], 'mchid' => $this->config['mchid'], 'description' => $description, 'out_trade_no' => $outTradeNo, 'notify_url' => $this->config['notify_url'], 'amount' => [ 'total' => $amount, 'currency' => 'CNY', ], 'payer' => [ 'openid' => $openid, ], ]; $result = $this->request('POST', '/v3/pay/transactions/jsapi', $body); if (!isset($result['prepay_id'])) { throw new RuntimeException('下单失败:' . json_encode($result)); } return $result; // prepay_id 在这里 }

out_trade_no 是商户订单号,必须保证唯一。同一笔订单重复下单,微信会直接返回之前生成的 prepay_id,这本身是个幂等保护,但也容易被误判成 bug。description 会展示在用户的支付账单里,别写纯技术字符串,写「商品名」这种用户看得懂的内容。

4.2 生成小程序拉起支付的 paySign

拿到 prepay_id 之后,不能直接丢给前端调用 wx.requestPayment。小程序端拉起支付还需要一组参数:timeStamp、nonceStr、package、signType、paySign。其中最容易被忽略的是 paySign 的签名串结构和请求头的签名串不一样:

public function buildPayParams(string $prepayId): array { $timestamp = (string) time(); $nonceStr = bin2hex(random_bytes(16)); $package = 'prepay_id=' . $prepayId; // 注意这里的签名串以 appid 开头,结尾是 package,不是请求体 $signStr = $this->config['appid'] . "\n" . $timestamp . "\n" . $nonceStr . "\n" . $package . "\n"; openssl_sign($signStr, $rawSign, $this->privateKey, OPENSSL_ALGO_SHA256); return [ 'timeStamp' => $timestamp, 'nonceStr' => $nonceStr, 'package' => $package, 'signType' => 'RSA', 'paySign' => base64_encode($rawSign), ]; }

timeStamp 必须是字符串类型,很多前端框架会把它当数字处理,导致签名不一致。paySign 依然是 SHA256withRSA,用的还是同一把商户私钥,但签名内容的格式完全不同。如果你把 buildAuthHeader 里那套方法拿来签这个串,前端拉起支付时就会报「支付签名验证失败」,但后端日志又看不到任何异常,属于典型的黑匣子问题。

4.3 回调验签与 AES-256-GCM 解密

支付完成后,微信会向下单时传的 notify_url 发起 POST 请求。通知内容不是明文,而是经过 AES-256-GCM 加密的 resource 对象。回调处理第一步是验签,第二步是解锁 resource,第三步才是更新订单。回调请求头和响应验签用的是同一套逻辑:

public function handleNotify(): array { $body = file_get_contents('php://input'); $headers = [ 'wechatpay-timestamp' => $_SERVER['HTTP_WECHATPAY_TIMESTAMP'] ?? '', 'wechatpay-nonce' => $_SERVER['HTTP_WECHATPAY_NONCE'] ?? '', 'wechatpay-serial' => $_SERVER['HTTP_WECHATPAY_SERIAL'] ?? '', 'wechatpay-signature' => $_SERVER['HTTP_WECHATPAY_SIGNATURE'] ?? '', ]; if (!$this->verifyResponse($headers, $body)) { return ['code' => 'FAIL', 'message' => '验签失败']; } $payload = json_decode($body, true); $resource = $payload['resource']; $plaintext = $this->decryptResource($resource); $event = json_decode($plaintext, true); // $event 里包含 out_trade_no、transaction_id、trade_state、amount、success_time 等 return ['code' => 'SUCCESS', 'message' => '成功', 'event' => $event]; }

decryptResource 和第 3 章解密平台证书用的是同一个方法,因为微信的加密数据格式统一都是 AEAD_AES_256_GCM。但有一个差异需要记住:平台证书的 encrypt_certificate 里 associated_data 有值,而支付通知的 resource 里 associated_data 通常是空字符串。代码里用的$encrypted['associated_data'] ?? ''已经同时兼容两种情况,别把这个字段写死。

4.4 幂等控制与 SUCCESS 应答协议

回调解出来的 event 里最关键的两个字段是 out_trade_no(商户订单号)和 transaction_id(微信支付订单号)。落库时必须以 transaction_id 为准做幂等,否则微信重试回调时,同一笔订单会被重复处理两次。常见做法是更新订单表时加状态条件:

UPDATE orders SET status = 'paid', transaction_id = :transaction_id, paid_at = :paid_at WHERE out_trade_no = :out_trade_no AND status = 'pending'

影响行数为 1 说明本次回调首次处理;影响行数为 0 说明订单已处理或订单号不存在,这时依然要返回 SUCCESS。微信的回调重试机制已经设置了退避间隔,但如果你漏回 2xx 状态码,重试会一直持续数小时。应答体必须是严格的 JSON,不能有 BOM,不能有多余的空格:

{"code":"SUCCESS","message":"成功"}

必须先落库,再返回 SUCCESS。反过来先应答再写库,万一写库失败,钱已经扣了但订单没更新,售后就要人工介入,这是支付系统里最不想遇到的场景。

5. 微信支付v3 高频翻车点排查清单

5.1 回调链路三连坑:验签失败、解密成空串、通知迟迟不来

坑一:回调验签持续性失败。现象是 verifyResponse 一直返回 false,日志里只有一行「验签失败」。原因通常是两个:用了商户证书的公钥去验签,或者平台证书已经轮换但本地还留着旧文件。微信返回的响应头 Wechatpay-Serial 会明确告诉你是哪个证书签的,直接拿这个序列号去本地找对应 PEM;找不到就去 /v3/certificates 拉一次新的。排查时先打印这个 header,再确认本地 platform 目录里是否存在同名文件,顺序不能反。

坑二:解密输出空字符串,openssl 报错。最典型的错误写法是没有拆分 ciphertext 尾部 16 字节的 tag,而是把 base64 解码后的整段密文同时当成密文和 tag 传给 openssl_decrypt。解密函数会把前 16 字节误当成校验值,然后又拿整段做解密,结果就是返回 false。解决方法是先 substr 拆出 tag,再用剩余部分解密。这也是本章反复强调的原因:这个代码如果你不写注释,三个月后回来看一定会骂当初的自己。

坑三:notify_url 一直收不到回调,订单状态停在「待支付」。微信支付的回调地址必须公网可访问,且不能是 IP 或 localhost。很多本地联调的时候随手填了个局域网地址,微信那边根本访问不到。再有就是下单时传的 notify_url 和商户平台配置的不一致,以请求参数里的为准,不是以商户平台的为准,别改错地方。

5.2 下单链路两连坑:appid 不匹配、金额单位翻车

坑四:下单报「appid 与 mchid 不匹配」或「openid 与 appid 不一致」。这条一般不是签名问题,而是商户号和小程序 AppID 没有在商户平台完成绑定。解决路径是:商户平台 → 产品中心 → AppID 账号管理,发起绑定,等小程序管理员确认。注意一个商户号可以绑定多个 AppID,但某笔交易里用的 openid 必须属于当前传入的 appid,不能把公众号的 openid 塞进小程序的支付请求。

坑五:金额单位翻车。V3 接口里金额单位是分,最小支付金额是 0.01 元,也就是请求体里的 total 不能小于 1。有的团队会在前端用元,接口里不转换,导致所有订单金额都放大了 100 倍。这个 bug 通常在第一个真实用户付款后才会被发现,属于上线前必须做校验的高危参数。建议在下单入口写一个断言,金额小于 1 或者不是整数直接抛异常,别等到微信返回错误码才排查。

6. 从「能付能回」到「能上线」:证书自动刷新、退款接口与调试自检

到这里,你已经有了一个完整的支付闭环。但在真正的生产环境里,有三件小事值得再往前推一步。

平台证书自动刷新是必须做的。微信支付平台证书平均每隔一段时间就会轮换,手工下载迟早踩坑。更稳的做法是让上面的 verifyResponse 方法在遇到未知序列号时自动触发 refreshPlatformCerts,刷新后重新验签一次。这样轮换期间的一两秒延迟只影响当前一个请求,而不是让整个支付链路挂掉。建议给 refresh 加一个互斥锁,避免并发请求同时拉取证书。

退款接口可以复用同一套签名逻辑。V3 的退款接口是POST /v3/refund/domestic/refunds,请求体里传 out_trade_no、out_refund_no、amount 数组,其中 amount.refund 是退款金额,amount.total 是原订单金额,金额单位同样是分。退款响应同样要走验签流程,这笔响应体里会直接返回退款单状态,不需要另外查单。

最后分享一个我长期保留的调试习惯:所有支付相关入口,第一行就把原始报文写到独立的日志文件里,不回显到业务日志。包括下单请求的完整 bodyJson、响应验签用的四个 header、回调的原始 body。排查支付问题最怕脑补报文,你以为微信发过来的是 A,实际可能是 B;有原始报文在手,百分之九十的问题能在一分钟内定位。这个习惯帮我在上一个项目里避免了一次通宵排查,那时候回调一直显示「等待中」,最后发现是本地日志目录权限不够,第一批日志压根没写进去。希望这条经验也能帮到你少走一段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询