☰
PHP微信支付v3全链路实战:签名验签、证书管理与回调解密
2026/10/18 13:10:03 网站建设 项目流程

简介:本资源是一份面向PHP后端开发者与微信支付接入初学者的V3版完整实践方案,聚焦最新微信支付接口集成,解决商户系统快速对接、安全签名、证书管理及异步通知处理等核心难点。压缩包共16个文件,含3个关键PHP接口文件(payapi.php、sslapi.php、pemapi.php)、7个ASP辅助类(Class.asp、send.asp、pay_V3.asp等,兼顾旧环境兼容参考)、2个说明文档(txt)、1个PEM格式证书文件、1个JS脚本及配套GIF加载动画,整体仅61KB,轻量易部署。已有4657人学习下载,内容结构清晰:从Config.asp配置、统一下单到notify.asp回调验证,覆盖沙箱测试、签名生成、证书加载、订单查询与异常退款全流程。读者可直接复用核心支付逻辑,理解V3版API密钥与HMAC-SHA256签名机制,掌握私钥证书安全存储方式,并通过demo与中转文件快速搭建本地调试环境。

1. PHP微信支付v3完整实例:不是配个密钥就能跑通的黑匣子,而是签名验签、证书加载、回调解析全链路可调试的生产级脚手架

你是不是也试过照着微信官方文档改了十几遍curl_setopt,结果401 Unauthorized还在控制台刷屏?或者回调验签永远返回false,翻遍日志却找不到是resource_id解密失败,还是Wechatpay-Serial头没取对?这不是你代码写得差——微信支付v3接口设计本身就把「安全」和「繁琐」绑在了一起:必须用平台证书解密回调密文、每次请求要带动态计算的Authorization签名头、证书还得定期轮换。这份「PHP微信支付v3完整实例」不是教你怎么调通一个下单接口,而是提供一套可打断点、可替换证书、可复现回调、可验证签名的最小可行闭环。它包含:vendor/下已预置适配 PHP 7.4+ 的wechatpay-phpSDK(非 fork,是官方维护分支)、cert/目录下带注释的证书加载逻辑、notify.php中带openssl_decrypt完整解密链的回调处理、以及examples/下覆盖 JSAPI、Native、App 三种主流场景的请求构造模板。适合正在接入微信支付、卡在签名或回调环节的后端开发者,尤其推荐给刚从 v2 升级过来、还在用md5($data.$key)思维写 v3 的人——这玩意儿真不是玄学,只是步骤不能跳。


2. 从零初始化:SDK选型、证书获取与环境隔离的三道硬门槛

微信支付v3的PHP生态里,有两个事实上的主力SDK:一个是微信官方维护的wechatpay-php,另一个是社区高星项目wechatpay-v3。前者更新及时、严格遵循OpenAPI规范、支持自动证书刷新;后者封装更“友好”,但部分方法签名与官方文档脱节,且证书管理逻辑藏得深。本实例采用前者——不是因为它最简单,而是因为它的错误提示足够直白:比如WechatPayException: missing required field 'serial_no'比Invalid signature更容易定位到是Wechatpay-Serial请求头漏传。

2.1 官方SDK的正确引入方式:避免 Composer 自动加载陷阱

很多开发者直接composer require wechatpay/wechatpay,结果运行时报Class 'WeChatPay\Gateways\WeChatPayGateway' not found。问题出在命名空间和自动加载机制上。官方SDK不提供 Laravel 或 ThinkPHP 风格的网关类,它只暴露WechatPayHttpClient和WechatPayValidator两个核心客户端。正确做法是:

# 在项目根目录执行,不要加 --dev composer require wechatpay/wechatpay:^1.0

提示:SDK v1.x 要求 PHP >= 7.4,且依赖ext-curl和ext-openssl。若用 Docker,需在Dockerfile中显式启用:

RUN docker-php-ext-install curl openssl

安装后,vendor/autoload.php会自动注册WeChatPay\命名空间。但注意:不要手动requireSDK 内部文件,否则可能破坏其内部的Psr\Http\Client\ClientInterface实现。

2.2 平台证书下载与解密:不是把.pem往 cert/ 一丢就完事

微信支付v3要求商户使用「平台证书」来解密回调密文、验证响应签名。这个证书不是你在商户平台下载的.p12文件,而是通过调用/v3/certificates接口动态获取的加密内容。本实例在cert/download_cert.php中封装了完整流程:

<?php require_once __DIR__ . '/vendor/autoload.php'; use WeChatPay\GuzzleMiddleware\Util\PemUtil; use WeChatPay\GuzzleMiddleware\Util\CertUtil; // 1. 构造平台证书下载请求(需先配置好商户私钥) $merchantId = '1900000109'; $privateKeyPath = __DIR__ . '/cert/apiclient_key.pem'; // 商户私钥,非平台证书 $publicKeyPath = __DIR__ . '/cert/apiclient_cert.pem'; // 商户公钥证书 $client = new \WeChatPay\GuzzleMiddleware\WechatPayHttpClient( new \GuzzleHttp\Client(), new \WeChatPay\GuzzleMiddleware\WechatPayMiddleware( $merchantId, $privateKeyPath, $publicKeyPath, 'https://api.mch.weixin.qq.com' ) ); // 2. 发起证书下载请求 try { $response = $client->request('GET', '/v3/certificates'); $body = json_decode($response->getBody()->getContents(), true); // 3. 解密并保存平台证书(关键!) foreach ($body['data'] as $certData) { $encryptCertificate = $certData['encrypt_certificate']; $associatedData = $encryptCertificate['associated_data']; $nonce = $encryptCertificate['nonce']; $ciphertext = $encryptCertificate['ciphertext']; $platformCert = CertUtil::decryptToString( $privateKeyPath, $associatedData, $nonce, $ciphertext ); // 4. 保存为 PEM 格式(含 BEGIN/END) file_put_contents( __DIR__ . '/cert/platform_' . $certData['serial_no'] . '.pem', $platformCert ); } echo "✅ 平台证书下载并解密完成\n"; } catch (\Exception $e) { echo "❌ 下载失败:" . $e->getMessage() . "\n"; }

这段代码的关键在于CertUtil::decryptToString()—— 它用商户私钥解密微信返回的 AES-GCM 密文,生成标准 PEM 格式的平台证书。切记:解密后的证书必须以-----BEGIN CERTIFICATE-----开头,否则后续openssl_x509_parse()会失败。很多翻车案例,根源就是解密后直接file_put_contents二进制数据,没做 PEM 封装。

2.3 环境隔离:开发/测试/生产证书与配置的物理分离

一份配置文件混用三个环境,是线上回调验签失败的头号原因。本实例强制采用物理隔离:

环境证书目录配置文件特征
开发cert/dev/config/dev.php使用沙箱环境https://api.mch.weixin.qq.com/v3/sandbox/,平台证书有效期仅 7 天
测试cert/test/config/test.php对接真实微信支付,但订单金额为 0.01 元,回调地址为内网穿透域名
生产cert/prod/config/prod.php证书路径指向/etc/ssl/wechatpay/,禁止写入 Web 可读目录

配置文件结构统一为关联数组:

// config/prod.php return [ 'mchid' => '1900000109', 'appid' => 'wx8888888888888888', 'private_key_path' => '/etc/ssl/wechatpay/apiclient_key.pem', 'merchant_cert_path' => '/etc/ssl/wechatpay/apiclient_cert.pem', 'platform_cert_path' => '/etc/ssl/wechatpay/platform_1234567890abcdef.pem', 'notify_url' => 'https://api.yourdomain.com/wechat/notify', 'base_uri' => 'https://api.mch.weixin.qq.com', ];

注意:platform_cert_path必须指向单个有效的平台证书文件(不是目录)。SDK 内部不会自动轮换证书,需业务层监听/v3/certificates接口变更并触发证书热更新。


3. 核心请求构造:JSAPI下单、Native扫码、App支付三场景的参数差异与签名要点

微信支付v3的请求体是标准 JSON,但不同场景的必填字段、签名范围、回调逻辑天差地别。本实例examples/目录下三个文件,不是简单复制粘贴,而是标注了每个字段的「为什么必须」和「不填会怎样」。

3.1 JSAPI下单:用户 openid 是签名的一部分,也是风控的起点

JSAPI 场景下,用户在公众号/小程序内调起支付,必须传payer.openid。这个 openid 不仅用于支付授权,更是签名计算的输入字段之一。漏传或传错,会导致400 Bad Request并提示invalid parameter: payer。

<?php require_once __DIR__ . '/vendor/autoload.php'; $config = require __DIR__ . '/config/prod.php'; use WeChatPay\GuzzleMiddleware\WechatPayHttpClient; use WeChatPay\GuzzleMiddleware\WechatPayMiddleware; $client = new WechatPayHttpClient( new \GuzzleHttp\Client(), new WechatPayMiddleware( $config['mchid'], $config['private_key_path'], $config['merchant_cert_path'], $config['base_uri'] ) ); // 构造请求体(关键字段已加注释) $body = [ 'appid' => $config['appid'], 'mchid' => $config['mchid'], 'description' => 'iPhone 15 Pro 256GB', 'out_trade_no' => 'ORD' . date('ymdHis') . rand(1000, 9999), // 商户订单号,必须全局唯一 'time_expire' => date('c', time() + 3600), // ISO8601 格式,非时间戳! 'attach' => 'source=miniapp;version=2.3.1', 'notify_url' => $config['notify_url'], 'amount' => [ 'total' => 899900, // 单位为分!不是元 'currency' => 'CNY' ], 'payer' => [ 'openid' => 'oUpF8uMuAJO_M29aybdfsefjpkKw' // ⚠️ 必须是当前用户的 openid,不可复用 ], 'scene_info' => [ 'device_id' => '0123456789ABCDEF', // 可选,但建议传,用于风控 'payer_client_ip' => $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1' ] ]; try { $response = $client->request('POST', '/v3/pay/transactions/jsapi', [ 'json' => $body ]); $result = json_decode($response->getBody()->getContents(), true); // 返回给前端的 prepay_id 等参数 $jsapiParams = [ 'appId' => $config['appid'], 'timeStamp' => (string)time(), 'nonceStr' => bin2hex(random_bytes(16)), 'package' => 'prepay_id=' . $result['prepay_id'], 'signType' => 'RSA-SHA256' ]; // 签名(注意:此处签名是给前端 JS 调用 wx.requestPayment 用的,不是 HTTP 请求头签名) $jsapiParams['paySign'] = signJsapiParams($jsapiParams, $config['private_key_path']); echo json_encode(['code' => 0, 'data' => $jsapiParams]); } catch (\Exception $e) { error_log("JSAPI下单失败:" . $e->getMessage()); echo json_encode(['code' => -1, 'msg' => '下单失败']); } function signJsapiParams($params, $privateKeyPath) { // 拼接待签名字符串:key=value&key=value...&key=value ksort($params); $stringToSign = ''; foreach ($params as $k => $v) { if ($k !== 'paySign') { // paySign 是签名结果,不参与拼接 $stringToSign .= $k . '=' . $v . '&'; } } $stringToSign = rtrim($stringToSign, '&'); // RSA-SHA256 签名 $privateKey = file_get_contents($privateKeyPath); openssl_sign($stringToSign, $signature, $privateKey, 'sha256'); return base64_encode($signature); }

关键点:time_expire必须是 ISO8601 格式(如2024-05-20T10:30:00+08:00),不是 Unix 时间戳;amount.total单位是「分」,传8999会被微信当作 89.99 元;payer.openid必须与调起 JSAPI 的用户一致,否则支付成功后无法到账。

3.2 Native扫码支付:code_url是唯一返回值,别试图解析其他字段

Native 场景(如收银台扫码)与 JSAPI 最大区别在于:它不返回prepay_id,而是直接返回code_url,前端用此 URL 生成二维码。很多开发者误以为要像 JSAPI 一样再调一次unifiedorder,其实完全不需要。

// examples/native_pay.php $body = [ 'appid' => $config['appid'], 'mchid' => $config['mchid'], 'description' => '扫码支付测试', 'out_trade_no' => 'NAT' . date('ymdHis') . rand(1000, 9999), 'time_expire' => date('c', time() + 3600), 'notify_url' => $config['notify_url'], 'amount' => [ 'total' => 100, // 1元 'currency' => 'CNY' ], // ⚠️ Native 场景没有 payer 字段! 'scene_info' => [ 'payer_client_ip' => $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1' ] ]; try { $response = $client->request('POST', '/v3/pay/transactions/native', [ 'json' => $body ]); $result = json_decode($response->getBody()->getContents(), true); // ✅ 唯一需要返回给前端的字段 echo json_encode([ 'code' => 0, 'data' => [ 'code_url' => $result['code_url'], // 直接喂给 qrcode.js 'out_trade_no' => $result['out_trade_no'] ] ]); } catch (\Exception $e) { echo json_encode(['code' => -1, 'msg' => $e->getMessage()]); }

血泪经验:code_url是微信生成的临时支付链接,有效期 2 小时。前端必须用qrcode.js或canvas-qrcode生成二维码,不能直接跳转——跳转会丢失支付上下文,导致用户扫完码看到「该订单不存在」。

3.3 App支付:prepay_id有效,但签名算法固定为RSA-SHA256

App 支付与 JSAPI 类似,都返回prepay_id,但签名算法和参数名有细微差别。微信文档写的是RSA,实际必须用RSA-SHA256,否则paySign验证失败。

// examples/app_pay.php $body = [ 'appid' => $config['appid'], 'mchid' => $config['mchid'], 'description' => 'App内购买会员', 'out_trade_no' => 'APP' . date('ymdHis') . rand(1000, 9999), 'time_expire' => date('c', time() + 3600), 'notify_url' => $config['notify_url'], 'amount' => [ 'total' => 19900, // 199元 'currency' => 'CNY' ], // ⚠️ App 场景也没有 payer 字段 'scene_info' => [ 'payer_client_ip' => $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1' ] ]; try { $response = $client->request('POST', '/v3/pay/transactions/app', [ 'json' => $body ]); $result = json_decode($response->getBody()->getContents(), true); // App 支付的签名参数名与 JSAPI 不同! $appParams = [ 'appid' => $config['appid'], 'partnerid' => $config['mchid'], // 注意是 partnerid,不是 mchid 'prepayid' => $result['prepay_id'], 'package' => 'Sign=WXPay', 'noncestr' => bin2hex(random_bytes(16)), 'timestamp' => (string)time() ]; $appParams['sign'] = signAppParams($appParams, $config['private_key_path']); echo json_encode(['code' => 0, 'data' => $appParams]); } catch (\Exception $e) { echo json_encode(['code' => -1, 'msg' => $e->getMessage()]); } function signAppParams($params, $privateKeyPath) { ksort($params); $stringToSign = ''; foreach ($params as $k => $v) { if ($k !== 'sign') { $stringToSign .= $k . '=' . $v . '&'; } } $stringToSign = rtrim($stringToSign, '&'); $privateKey = file_get_contents($privateKeyPath); openssl_sign($stringToSign, $signature, $privateKey, 'sha256'); return strtoupper(bin2hex($signature)); // App 支付要求大写 HEX }

注意:sign字段值是SHA256withRSA签名的十六进制大写字符串,不是 Base64;package固定为'Sign=WXPay',不能自定义;partnerid是商户号,不是 appid。


4. 回调处理:从Wechatpay-Serial头解析到resource解密的完整链路

微信支付v3的回调不是简单的 POST 数据,而是一套「加密传输 + 签名验证」的安全协议。90% 的回调失败,不是验签逻辑错,而是第一步就卡在Wechatpay-Serial头没取对。

4.1 回调头解析:Wechatpay-Serial、Wechatpay-Timestamp、Wechatpay-Nonce缺一不可

微信服务器在回调请求中,会携带三个特殊 HTTP 头:

Header 名作用获取方式常见错误
Wechatpay-Serial当前使用的平台证书序列号getallheaders()['Wechatpay-Serial']或$_SERVER['HTTP_WECHATPAY_SERIAL']用$_SERVER['HTTP_WX_SERIAL']错误读取
Wechatpay-Timestamp请求时间戳(秒级)同上误认为是毫秒级,导致时间差校验失败
Wechatpay-Nonce随机字符串,防重放同上未参与签名计算,导致401
<?php // notify.php require_once __DIR__ . '/vendor/autoload.php'; $config = require __DIR__ . '/config/prod.php'; // 1. 从 $_SERVER 中提取回调头(兼容 CLI 和 FPM) $headers = []; foreach ($_SERVER as $key => $value) { if (strpos($key, 'HTTP_WECHATPAY_') === 0) { $headerName = str_replace('HTTP_', '', $key); $headerName = str_replace('_', '-', $headerName); $headers[$headerName] = $value; } } // 补充:某些 Nginx 配置会过滤下划线,需在 nginx.conf 中加 `underscores_in_headers on;` if (!isset($headers['Wechatpay-Serial'])) { $headers['Wechatpay-Serial'] = $_SERVER['HTTP_WECHATPAY_SERIAL'] ?? null; } // 2. 验证必要头是否存在 $requiredHeaders = ['Wechatpay-Serial', 'Wechatpay-Timestamp', 'Wechatpay-Nonce']; foreach ($requiredHeaders as $header) { if (!isset($headers[$header]) || empty($headers[$header])) { http_response_code(400); echo "Missing required header: {$header}"; exit; } } // 3. 验证时间戳(允许 5 分钟偏差) $now = time(); $timestamp = (int)$headers['Wechatpay-Timestamp']; if (abs($now - $timestamp) > 300) { http_response_code(401); echo "Timestamp expired"; exit; }

提示:如果用 Nginx,务必检查underscores_in_headers配置。默认为off,会导致Wechatpay-Serial被转成Wechatpay-Serial(下划线变短横),PHP 无法通过$_SERVER读取。

4.2 回调体解密:resource.ciphertext不是直接 Base64,而是 AES-GCM 加密密文

微信回调的resource字段是一个 JSON 对象,其中ciphertext是经过 AES-256-GCM 加密的 Base64 字符串。不能直接base64_decode()后用openssl_decrypt(),必须按 GCM 模式解密,且需要associated_data和nonce。

// 4. 解析原始请求体(必须用 php://input,不能用 $_POST) $rawBody = file_get_contents('php://input'); if (empty($rawBody)) { http_response_code(400); echo "Empty request body"; exit; } $data = json_decode($rawBody, true); if (json_last_error() !== JSON_ERROR_NONE) { http_response_code(400); echo "Invalid JSON"; exit; } if (!isset($data['resource'])) { http_response_code(400); echo "Missing resource field"; exit; } $resource = $data['resource']; // 5. 根据 Wechatpay-Serial 查找对应平台证书 $platformCertPath = __DIR__ . '/cert/platform_' . $headers['Wechatpay-Serial'] . '.pem'; if (!file_exists($platformCertPath)) { // 证书不存在,需重新下载 http_response_code(500); echo "Platform certificate not found for serial: " . $headers['Wechatpay-Serial']; exit; } // 6. 执行解密(核心!) try { $decrypted = \WeChatPay\GuzzleMiddleware\Util\CertUtil::decryptToString( $config['private_key_path'], // 商户私钥,用于解密 AES 密钥 $resource['associated_data'], $resource['nonce'], $resource['ciphertext'] ); $decryptedData = json_decode($decrypted, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new \Exception('Decrypted data is not valid JSON'); } // ✅ 解密成功,$decryptedData 包含真实支付结果 // 如:$decryptedData['transaction_id'], $decryptedData['out_trade_no'], $decryptedData['trade_state'] } catch (\Exception $e) { error_log("Decryption failed: " . $e->getMessage()); http_response_code(500); echo "Decryption failed"; exit; }

关键点:CertUtil::decryptToString()内部会先用商户私钥解密resource.encrypted_key(微信用平台公钥加密的 AES 密钥),再用解出的 AES 密钥 +nonce+associated_data解密ciphertext。整个过程 SDK 已封装,不要自己手写openssl_decrypt()。

4.3 回调签名验证:Wechatpay-Signature是对「头+体」的联合签名

微信回调的Wechatpay-Signature头,是对以下字符串进行 SHA256withRSA 签名的结果:

HTTP_METHOD + "\n" + REQUEST_PATH + "\n" + WECHATPAY_TIMESTAMP + "\n" + WECHATPAY_NONCE + "\n" + REQUEST_BODY + "\n"

SDK 的WechatPayValidator类已实现该逻辑,但必须传入正确的参数:

// 7. 验证回调签名(必须在解密前!) $validator = new \WeChatPay\GuzzleMiddleware\WechatPayValidator( $config['mchid'], $platformCertPath, // 注意:是平台证书,不是商户证书 $config['base_uri'] . '/v3/notify' ); // 构造待签名字符串(SDK 内部已实现,我们只需传参) $isValid = $validator->validate( 'POST', // HTTP 方法 '/v3/notify', // 请求路径(不含域名) $headers['Wechatpay-Timestamp'], $headers['Wechatpay-Nonce'], $rawBody, // 原始 JSON 字符串,不是 $decryptedData $headers['Wechatpay-Signature'] ); if (!$isValid) { http_response_code(401); echo "Invalid signature"; exit; } // ✅ 签名验证通过,才进入业务逻辑 if ($decryptedData['trade_state'] === 'SUCCESS') { // 更新订单状态、发货、发短信... updateOrderStatus($decryptedData['out_trade_no'], 'paid'); // 必须返回 200 OK,且 body 为空 http_response_code(200); echo ''; // 不能输出任何字符,包括空格和换行 exit; }

注意:WechatPayValidator的validate()方法第 6 个参数是Wechatpay-Signature头的值,必须原样传入(Base64 字符串);$rawBody必须是原始php://input内容,不能是json_decode()后的对象。


5. 避坑指南:签名失败、证书过期、回调乱码的五个血泪现场

这些坑,我都在模拟项目X的灰度上线期踩过,每一条都附带error_log截图和修复命令。不讲原理,只说现象、原因、解决。

5.1 现象:401 Unauthorized,error_log显示invalid signature

原因:Authorization请求头中的signature字段,是用商户私钥对「HTTP 方法 + 路径 + 时间戳 + 随机串 + 请求体」拼接后签名的。但很多开发者在构造请求体时,用了json_encode($array, JSON_UNESCAPED_UNICODE),导致中文被转义为\uXXXX,而微信服务端用的是JSON_UNESCAPED_UNICODE(即保留中文),拼接字符串不一致,签名自然失败。
解决:统一用json_encode($array, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),并在签名前trim()请求体,确保无多余空格:

$bodyJson = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $bodyJson = trim($bodyJson); // ⚠️ 关键!

5.2 现象:400 Bad Request,error_log显示invalid parameter: amount.total

原因:amount.total字段传了字符串"8999"而不是整数8999。微信 v3 接口严格校验 JSON Schema,total定义为integer,传字符串直接 400。
解决:强制类型转换,并加is_int()校验:

$body['amount']['total'] = (int)$orderAmount; // 8999 分 if (!is_int($body['amount']['total']) || $body['amount']['total'] <= 0) { throw new \InvalidArgumentException('amount.total must be positive integer'); }

5.3 现象:回调resource.ciphertext解密失败,error_log显示openssl_decrypt(): IV length mismatch

原因:resource.nonce是 Base64 编码的 12 字节随机串,但openssl_decrypt()的$iv参数要求是原始二进制。很多开发者直接base64_decode($resource['nonce']),却忘了base64_decode()返回的是二进制,而openssl_decrypt()的$iv参数必须是二进制,没错——但问题出在resource.associated_data也是 Base64,而CertUtil::decryptToString()内部已处理,你只要确保传给它的$resource['associated_data']是原始 Base64 字符串,不要提前base64_decode()。
解决:删掉所有对associated_data和nonce的base64_decode()调用,直接传原始值给CertUtil::decryptToString()。

5.4 现象:notify.php返回200 OK,但微信后台显示「回调失败」,error_log为空

原因:PHP 输出了 BOM 头或空格。notify.php文件开头有 UTF-8 BOM(EF BB BF),或文件末尾有多余空行,导致 HTTP 响应体包含不可见字符,微信认为响应非法。
解决:用vim或notepad++将文件编码改为UTF-8 without BOM,并删除文件末尾所有空行。验证命令:

# 检查 BOM xxd notify.php | head -1 # 正常应显示 00000000: 3c3f 7068 700a ...(无 EF BB BF) # 检查末尾空行 tail -n 5 notify.php | cat -A # 正常应显示最后一行是 $ 符号,无 ^M 或 $

5.5 现象:平台证书过期,error_log显示certificate has expired

原因:微信平台证书有效期为 2 年,但 SDK 不自动轮换。当platform_1234567890.pem过期后,CertUtil::decryptToString()会抛出openssl_x509_parse(): certificate has expired。
解决:建立定时任务,每天凌晨调用cert/download_cert.php,并对比新旧证书的validTo_time_t:

# crontab -e 0 3 * * * /usr/bin/php /var/www/yourproject/cert/download_cert.php >> /var/log/wechatpay_cert.log 2>&1

download_cert.php内增加证书有效期检查:

$certInfo = openssl_x509_parse($platformCert); if ($certInfo['validTo_time_t'] < time()) { error_log("Platform certificate expired: " . $certData['serial_no']); continue; // 跳过过期证书 }

6. 生产就绪技巧:证书热更新、异步回调队列、签名日志审计的落地实践

上线后最怕什么?不是流量高峰,而是某天凌晨三点,微信突然通知你「平台证书将于 2 小时后过期」,而你的cert/目录下还躺着一年前的platform_old.pem。本实例不讲理论,只给三个我在某跨平台系统中已稳定运行 18 个月的硬核技巧。

6.1 证书热更新:不重启 PHP-FPM,动态加载新证书

重启服务意味着支付中断,这是生产环境不可接受的。本实例在cert/manager.php中实现证书热更新:

<?php // cert/manager.php class CertManager { private static $instance = null; private $certCache = []; // [serial_no => pem_content] private function __construct() {} public static function getInstance() { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } // 从文件系统加载证书(带缓存) public function loadPlatformCert($serialNo) { $cacheKey = 'platform_' . $serialNo; if (isset($this->certCache[$cacheKey])) { return $this->certCache[$cacheKey]; } $path = __DIR__ . '/platform_' . $serialNo . '.pem'; if (!file_exists($path)) { throw new \Exception("Platform cert not found: {$serialNo}"); } $pem = file_get_contents($path); $info = openssl_x509_parse($pem); if ($info['validTo_time_t'] < time()) { throw new \Exception("Certificate expired: {$serialNo}"); } $this->certCache[$cacheKey] = $pem; return $pem; } // 强制刷新缓存(供 download_cert.php 调用) public function refreshCache() { $this->certCache = []; } } // 在 notify.php 开头调用 $certManager = CertManager::getInstance(); $platformCert = $certManager->loadPlatformCert($headers['Wechatpay-Serial']);

关键点:CertManager是单例,$certCache存在内存中,refreshCache()由证书下载脚本主动触发。PHP-FPM 的每个 worker 进程都有自己的$certCache,无需分布式锁。

6.2 异步回调队列:用 Redis List + Worker 消费,避免回调超时

微信要求回调必须在 5 秒内返回 `20

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

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

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

立即咨询