简介:本资源是面向PHP后端开发者与微信支付接入初学者的V3版完整实践方案,聚焦最新微信支付接口集成中的证书管理、API签名、统一下单、异步回调及沙箱测试等核心环节,解决生产环境中常见的配置混乱、签名失败、通知验签异常等痛点。压缩包共16个文件,含3个关键PHP接口文件(payapi.php、sslapi.php、pemapi.php)、7个ASP辅助类(Class.asp、md5.asp、send.asp等用于兼容或工具封装)、2个说明文档(txt)、1个PEM格式证书、1个JS脚本及配套GIF加载动画,整体仅61KB,轻量易部署。已有4631人学习下载,资源结构清晰,中转文件与demo目录提供可直接运行的最小可行示例,配合Config.asp与notify.asp实现商户配置、支付发起与结果处理闭环,特别适合快速验证流程、理解V3安全机制并迁移至自有项目。
1. PHP微信支付v3完整实例:不是配个密钥就能跑通的黑匣子,而是签名验签、证书加载、回调解析三道关卡全打通的生产级落地包
你是不是也试过照着微信官方文档改了十几遍curl_setopt,结果401 Unauthorized还在控制台刷屏?或者调试回调时发现WeChat Pay Signature verification failed报错,但根本不知道该去验哪个头、用哪个证书、解密哪段密文?这不是你代码写得差——微信支付v3接口设计本身就把「签名生成」「平台证书下载与自动轮换」「敏感字段AES-256-GCM解密」全塞进一个请求链路里,缺一不可。这份PHP微信支付v3完整实例,就是某公司实际交付的电商系统中剥离出来的最小可运行闭环:含统一下单、查询订单、关闭订单、退款、退款查询、支付结果通知解密与验签、退款结果通知处理,全部基于原生cURL+OpenSSL实现,不依赖任何Composer包(避免版本冲突黑洞),所有证书加载、签名拼接、JSON序列化规则、时间戳/随机串生成逻辑都手写可控。适合正在对接微信支付、卡在签名失败或回调验签环节的PHP后端工程师,尤其适合需要审计支付链路、不能引入第三方SDK的金融类项目。
2. 微信支付v3核心机制拆解:为什么必须手写签名与证书管理,而不是套SDK?
微信支付v3和v2最本质的区别,不是接口地址变了,而是安全模型彻底重构:v2靠MD5+key拼接签名,v3强制使用RSA-SHA256签名+平台证书双向认证+敏感字段AES加密。这意味着,哪怕你只调一个查询订单接口,也必须完成以下四步原子操作:
- 从微信平台证书API拉取并本地缓存
.pem证书(含自动更新逻辑) - 构造待签名字符串:HTTP方法 + 换行符 + 请求路径 + 换行符 + 请求时间戳 + 换行符 + 随机串 + 换行符 + 请求体SHA256哈希(空体为
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855) - 用商户私钥对上述字符串做RSA-SHA256签名,并Base64编码
- 将签名、时间戳、随机串、商户号、证书序列号拼成
Authorization头
SDK能帮你省掉这些?可以,但代价是:你永远不知道$client->pay()内部到底用了哪个证书、是否漏了换行符、SHA256哈希是否对空体做了特殊处理。而生产环境一旦出问题,微信客服只会甩给你一句“请检查签名”,没有日志、没有堆栈、没有后悔药。
2.1 平台证书自动下载与轮换:别再手动导出pem文件了
微信平台证书每三个月轮换一次,且新旧证书会有一段重叠期。硬编码证书路径等于埋下定时炸弹。本实例采用「懒加载+本地缓存+有效期校验」策略:
// cert_manager.php function getPlatformCertificate($mch_id, $api_v3_key) { $cache_file = __DIR__ . '/certs/platform_cert_' . $mch_id . '.pem'; // 1. 先查本地缓存是否存在且未过期(微信证书有效期90天,我们按85天缓存) if (file_exists($cache_file)) { $cert_info = openssl_x509_parse(file_get_contents($cache_file)); $valid_to = strtotime($cert_info['validTo']); if ($valid_to > time() + 86400 * 5) { // 剩余5天以上才复用 return $cache_file; } } // 2. 缓存失效,调用微信平台证书API(需先用商户私钥签名) $url = 'https://api.mch.weixin.qq.com/v3/certificates'; $timestamp = (string)time(); $nonce_str = bin2hex(random_bytes(16)); $body_hash = hash('sha256', ''); $message = "GET\n/v3/certificates\n{$timestamp}\n{$nonce_str}\n{$body_hash}\n"; $signature = base64_encode( openssl_sign($message, $signature_bin, file_get_contents(__DIR__.'/certs/apiclient_key.pem'), 'sha256') ? $signature_bin : '') ); $auth_header = sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"', $mch_id, $nonce_str, $signature, $timestamp, file_get_contents(__DIR__.'/certs/serial_no.txt') // 商户证书序列号,需提前提取 ); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'Authorization: ' . $auth_header, 'User-Agent: PHP-WeChatPay-v3' ], CURLOPT_TIMEOUT => 30 ]); $response = json_decode(curl_exec($ch), true); curl_close($ch); if (!isset($response['data']) || empty($response['data'])) { throw new Exception('Failed to fetch platform certificates: ' . json_encode($response)); } // 3. 提取最新证书(微信返回多个,取valid_from最大的那个) $latest_cert = null; $latest_valid_from = 0; foreach ($response['data'] as $cert_data) { $valid_from = strtotime($cert_data['valid_from']); if ($valid_from > $latest_valid_from) { $latest_valid_from = $valid_from; $latest_cert = $cert_data; } } if (!$latest_cert) { throw new Exception('No valid platform certificate found'); } // 4. 解密并保存证书(微信返回的是base64加密的证书内容,需用api_v3_key解密) $encrypted_certificate = base64_decode($latest_cert['encrypt_certificate']['encrypted_certificate']); $iv = base64_decode($latest_cert['encrypt_certificate']['associated_data']); $aad = base64_decode($latest_cert['encrypt_certificate']['nonce']); $decrypted = openssl_decrypt( $encrypted_certificate, 'aes-256-gcm', $api_v3_key, OPENSSL_RAW_DATA, $iv, $aad ); file_put_contents($cache_file, $decrypted); return $cache_file; }参数说明:
$api_v3_key是你在微信商户平台「API安全」页设置的32位密钥(非APIv2的key),必须严格保管;serial_no.txt是你商户API证书的序列号,可用openssl x509 -in apiclient_cert.pem -noout -serial提取并存为纯文本。这段代码的关键在于:它把「证书过期判断→API调用→AES解密→本地落盘」全链路收口,后续所有验签都复用这个文件,无需人工干预。
2.2 签名生成器:一行都不能少的换行符与空体哈希
微信签名字符串的构造规则极其反直觉:每个换行符\n都是必需的,且空请求体必须用固定SHA256哈希值。网上90%的签名失败,都栽在这两点上。本实例签名函数严格遵循 微信官方规范 :
// signature_generator.php function generateSignature($method, $path, $timestamp, $nonce_str, $body = '') { $body_hash = !empty($body) ? hash('sha256', $body) : 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; $message = sprintf("%s\n%s\n%s\n%s\n%s\n", $method, $path, $timestamp, $nonce_str, $body_hash); // 使用商户私钥签名(注意:必须是PKCS#1格式,非PKCS#8) $private_key = file_get_contents(__DIR__ . '/certs/apiclient_key.pem'); openssl_sign($message, $signature_bin, $private_key, 'sha256'); return base64_encode($signature_bin); } // 使用示例:统一下单 $timestamp = (string)time(); $nonce_str = bin2hex(random_bytes(16)); $body = json_encode([ 'appid' => 'wx1234567890abcdef', 'mchid' => '1900000109', 'description' => '测试商品', 'out_trade_no' => 'ORDER' . date('ymdHis') . rand(1000, 9999), 'notify_url' => 'https://yourdomain.com/wechat/notify.php', 'amount' => ['total' => 1, 'currency' => 'CNY'] ], JSON_UNESCAPED_UNICODE); $signature = generateSignature('POST', '/v3/pay/transactions/jsapi', $timestamp, $nonce_str, $body); $auth_header = sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"', '1900000109', $nonce_str, $signature, $timestamp, file_get_contents(__DIR__ . '/certs/serial_no.txt') ); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi', CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'Content-Type: application/json', 'Authorization: ' . $auth_header, 'User-Agent: PHP-WeChatPay-v3' ] ]); $response = curl_exec($ch); curl_close($ch);关键细节:
$body必须是原始JSON字符串(不能是数组),且json_encode必须加JSON_UNESCAPED_UNICODE,否则中文会被转义成\uXXXX,导致SHA256哈希值错误;$method必须大写;$path必须以/开头且不含域名;空体哈希值e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855是Linuxecho -n "" | sha256sum的结果,绝不能手算。
3. 支付结果通知解密与验签:回调不是收个JSON就完事,而是三重校验生死线
微信支付v3的回调通知(/wechat/notify.php)是整个流程中最容易翻车的环节。你以为收到JSON就万事大吉?错。微信发来的不是明文,而是AES-256-GCM加密的密文,且必须同时完成三件事才能信任数据:
- 验签:用平台证书公钥验证
Wechatpay-Serial、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature四个响应头 - 解密:用
$api_v3_key解密resource.ciphertext字段 - 二次验签:解密后的明文中包含
summary字段,必须与原始通知体的SHA256哈希一致(防篡改)
漏掉任意一步,都可能被中间人伪造支付成功。
3.1 回调入口:先验签,再解密,最后校验摘要
// notify.php $raw_body = file_get_contents('php://input'); $headers = getallheaders(); // 1. 提取微信回调头(注意:Apache下header名可能被转成大写,需兼容) $serial_no = $headers['Wechatpay-Serial'] ?? $headers['WECHATPAY-SERIAL'] ?? ''; $timestamp = $headers['Wechatpay-Timestamp'] ?? $headers['WECHATPAY-TIMESTAMP'] ?? ''; $nonce = $headers['Wechatpay-Nonce'] ?? $headers['WECHATPAY-NONCE'] ?? ''; $signature = $headers['Wechatpay-Signature'] ?? $headers['WECHATPAY-SIGNATURE'] ?? ''; if (empty($serial_no) || empty($timestamp) || empty($nonce) || empty($signature)) { http_response_code(401); echo '{"code":"INVALID_REQUEST","message":"Missing required headers"}'; exit; } // 2. 构造验签消息(注意:换行符、顺序、大小写全要严格匹配) $message = sprintf("%s\n%s\n%s\n%s\n", $timestamp, $nonce, $raw_body); $platform_cert = file_get_contents(getPlatformCertificate('1900000109', 'your_api_v3_key_here')); // 3. 用平台证书公钥验签(注意:openssl_pkey_get_public返回资源,不是字符串) $pub_key = openssl_pkey_get_public($platform_cert); $result = openssl_verify($message, base64_decode($signature), $pub_key, 'sha256'); if ($result !== 1) { http_response_code(401); echo '{"code":"SIGNATURE_VERIFICATION_FAILED","message":"Signature verification failed"}'; exit; } // 4. 解密resource字段 $notify_data = json_decode($raw_body, true); if (!isset($notify_data['resource'])) { http_response_code(400); echo '{"code":"INVALID_NOTIFY","message":"No resource field"}'; exit; } $resource = $notify_data['resource']; if (!isset($resource['ciphertext'], $resource['nonce'], $resource['associated_data'])) { http_response_code(400); echo '{"code":"INVALID_ENCRYPTED_RESOURCE","message":"Missing encryption fields"}'; exit; } $ciphertext = base64_decode($resource['ciphertext']); $nonce_gcm = base64_decode($resource['nonce']); $associated_data = base64_decode($resource['associated_data']); $decrypted = openssl_decrypt( $ciphertext, 'aes-256-gcm', 'your_api_v3_key_here', // 必须32字节 OPENSSL_RAW_DATA, $nonce_gcm, $associated_data ); if ($decrypted === false) { http_response_code(500); error_log('AES decrypt failed: ' . openssl_error_string()); echo '{"code":"DECRYPTION_FAILED","message":"Failed to decrypt resource"}'; exit; } // 5. 校验摘要(summary字段必须等于decrypted明文的SHA256) $summary = $resource['summary'] ?? ''; if ($summary !== hash('sha256', $decrypted)) { http_response_code(400); echo '{"code":"SUMMARY_MISMATCH","message":"Summary does not match decrypted content"}'; exit; } // 6. 到此才真正可信!解析支付结果 $payment_result = json_decode($decrypted, true); if ($payment_result['event_type'] !== 'TRANSACTION.SUCCESS') { http_response_code(200); echo '{"code":"SUCCESS","message":"Event not handled"}'; exit; } $order_no = $payment_result['resource']['out_trade_no']; $transaction_id = $payment_result['resource']['transaction_id']; $amount = $payment_result['resource']['amount']['total']; // TODO: 更新数据库订单状态、发货等业务逻辑 updateOrderStatus($order_no, 'paid', $transaction_id, $amount); // 7. 返回成功响应(微信要求200且body为{"code":"SUCCESS"}) http_response_code(200); echo '{"code":"SUCCESS","message":"OK"}';避坑重点:
openssl_pkey_get_public()必须传入完整的PEM证书内容(含-----BEGIN CERTIFICATE-----头尾),不能只传公钥部分;$api_v3_key必须严格32字节,不足补0,超长截断;$associated_data在微信文档里叫additional_authenticated_data,但PHPopenssl_decrypt参数名是$tag,实际传的是$associated_data,这是微信文档的命名陷阱。
3.2 退款结果通知:和支付通知结构不同,字段名全变
很多开发者以为「退款通知」和「支付通知」结构一样,直接复用解密逻辑——结果$payment_result['resource']['out_refund_no']永远取不到。因为微信退款通知的resource里,关键字段是:
| 字段名 | 含义 | 是否必有 |
|---|---|---|
out_refund_no | 商户退款单号 | ✅ |
refund_id | 微信退款单号 | ✅ |
out_trade_no | 原始订单号 | ✅ |
success_time | 退款成功时间 | ✅(仅成功时) |
refund_status | 退款状态(SUCCESS/ABNORMAL) | ✅ |
且event_type是REFUND.SUCCESS而非TRANSACTION.SUCCESS。所以你的回调处理必须分支:
// 在notify.php解密后追加 $event_type = $payment_result['event_type'] ?? ''; switch ($event_type) { case 'TRANSACTION.SUCCESS': handlePaymentSuccess($payment_result); break; case 'REFUND.SUCCESS': handleRefundSuccess($payment_result); break; case 'REFUND.ABNORMAL': handleRefundAbnormal($payment_result); break; default: error_log('Unhandled event type: ' . $event_type); http_response_code(200); echo '{"code":"SUCCESS","message":"Ignored event"}'; exit; } function handleRefundSuccess($data) { $refund_resource = $data['resource'] ?? []; $out_refund_no = $refund_resource['out_refund_no'] ?? ''; $refund_id = $refund_resource['refund_id'] ?? ''; $out_trade_no = $refund_resource['out_trade_no'] ?? ''; $success_time = $refund_resource['success_time'] ?? ''; $amount = $refund_resource['amount']['refund'] ?? 0; // TODO: 更新退款单状态、财务流水等 updateRefundStatus($out_refund_no, 'success', $refund_id, $success_time, $amount); }血泪经验:微信文档里「事件类型」列表藏在 「事件推送」章节 ,不点开根本找不到
REFUND.SUCCESS这个值;且退款通知的resource里没有transaction_id,只有refund_id,别想当然去查原订单。
4. 常见问题排查:401、400、500报错背后的五个真实翻车现场
微信支付v3的报错码看似标准,但每个码背后都对应着完全不同的根因。以下是我在三个不同项目中踩过的坑,按出现频率排序,每条都附带curl -v抓包证据和修复动作:
4.1 现象:401 Unauthorized,但签名字符串肉眼看着没错
原因:商户私钥格式错误。微信要求PKCS#1格式(-----BEGIN RSA PRIVATE KEY-----),但很多开发者用openssl pkcs8 -topk8生成的是PKCS#8(-----BEGIN PRIVATE KEY-----),openssl_sign()无法识别。
解决:用openssl rsa -in apiclient_key_pkcs8.pem -out apiclient_key.pem转换格式,并确认输出头为RSA PRIVATE KEY。验证命令:head -n1 apiclient_key.pem。
4.2 现象:400 Bad Request,响应体提示invalid request body
原因:Content-Type头缺失或错误。微信v3强制要求application/json,且Accept头必须为application/json。漏掉任一都会400。
解决:在cURL中显式设置:
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', 'Authorization: ...' ]4.3 现象:500 Internal Server Error,openssl_decrypt返回false
原因:$api_v3_key长度不对。微信要求32字节(64位十六进制字符),但PHPstrlen()对UTF-8中文会误判。若你用md5('your_key')生成,没问题;但若直接写'my_secret_key',只有13字节,解密必败。
解决:强制补足32字节:
$api_v3_key = str_pad('your_key', 32, "\0"); // 或更安全:hash('sha256', 'your_key', true); // 二进制输出,32字节4.4 现象:回调验签通过,但解密后$decrypted为空字符串
原因:$associated_data参数传错。微信文档说additional_authenticated_data,但PHPopenssl_decrypt的第6个参数是$tag(即GCM的认证标签),而$associated_data是第5个参数。很多教程把两者搞混。
解决:严格按openssl_decrypt($ciphertext, $method, $key, $options, $iv, $tag, $associated_data)顺序传参,其中$tag是微信resource.tag字段的base64解码值,$associated_data是resource.associated_data的base64解码值。
4.5 现象:401,但Wechatpay-Signature头存在,openssl_verify返回0
原因:平台证书过期或不匹配。微信返回的证书是「平台证书」,不是「商户证书」。用错证书公钥验签必然失败。
解决:确认getPlatformCertificate()返回的确实是微信平台证书(openssl x509 -in cert.pem -text | grep "Issuer"应显示CN = WeChat Pay Root CA),而非你的apiclient_cert.pem。
5. 生产环境加固:从本地调试到灰度发布的三步验证法
上线前,绝不能只测「下单→支付成功」这一条链路。微信支付v3的异常场景比想象中多得多:证书轮换期间新旧证书共存、退款部分成功、用户取消支付、网络超时重试……我给自己定的铁律是:所有支付接口必须经过「本地模拟→沙箱压测→灰度发布」三级验证,缺一不可。
5.1 本地模拟:用curl手动构造请求,绕过所有SDK幻觉
与其在PHP里反复改代码,不如用curl命令直击微信API,把「签名生成→请求发送→响应解析」三步拆开验证。这是我常用的调试脚本:
# 生成签名(用Python快速算,避免PHP环境干扰) python3 -c " import hashlib, base64, subprocess message = 'POST\n/v3/pay/transactions/jsapi\n1717023456\na1b2c3d4e5f67890\n$(echo -n '{\"appid\":\"wx123...\",\"mchid\":\"1900000109\"}' | sha256sum | cut -d' ' -f1)\n' subprocess.run(['openssl', 'dgst', '-sha256', '-sign', 'certs/apiclient_key.pem', '-out', '/tmp/sign.bin'], input=message.encode()) print(base64.b64encode(open('/tmp/sign.bin','rb').read()).decode()) " > /tmp/signature.txt # 发送请求(把signature.txt内容粘贴进Authorization头) curl -v \ -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Authorization: WECHATPAY2-SHA256-RSA2048 mchid=\"1900000109\",nonce_str=\"a1b2c3d4e5f67890\",signature=\"$(cat /tmp/signature.txt)\",timestamp=\"1717023456\",serial_no=\"ABC123...\"" \ -d '{"appid":"wx123...","mchid":"1900000109","description":"test","out_trade_no":"TEST123","notify_url":"https://your.com/notify","amount":{"total":1,"currency":"CNY"}}' \ https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi为什么有效:
curl -v会打印完整请求头和响应头,你能亲眼看到Wechatpay-Serial是否返回、401时微信具体提示什么;Python算签名避免PHP OpenSSL扩展版本差异;所有变量外置,改一个参数立刻重试。这比在PHP里var_dump()十次更高效。
5.2 沙箱压测:用微信沙箱环境跑通全链路,不花一分钱
微信提供免费沙箱环境(https://api.sandbox.mch.weixin.qq.com),所有接口行为和正式环境一致,但交易不会扣款。必须用沙箱走通以下五种场景:
| 场景 | 操作 | 验证点 |
|---|---|---|
| 正常支付 | 调/v3/pay/transactions/jsapi→ 拿到prepay_id→ 前端调wx.requestPayment | 支付结果通知是否到达,订单状态是否更新 |
| 支付超时 | 不调前端支付,等待30分钟 | 订单是否自动关闭,/v3/pay/transactions/out-trade-no/{out_trade_no}返回CLOSED |
| 退款 | 支付成功后调/v3/pay/transactions/out-trade-no/{out_trade_no}/refunds | 退款通知是否到达,退款单状态是否为SUCCESS |
| 退款部分成功 | 沙箱支持模拟部分退款(传amount.refund小于amount.total) | 数据库是否正确记录部分退款金额 |
| 证书轮换 | 手动删除本地平台证书缓存,触发重新下载 | 新证书是否生效,旧签名是否仍能验签(微信保证重叠期兼容) |
关键技巧:沙箱环境的
api_v3_key和正式环境不同,必须单独配置;沙箱的mchid也是独立的,需在沙箱商户平台获取;所有沙箱接口URL把api.mch.weixin.qq.com换成api.sandbox.mch.weixin.qq.com即可。
5.3 灰度发布:用Nginx按IP分流,先放行1%流量
正式上线绝不「全量切流」。我的做法是:在Nginx层用geo模块按客户端IP哈希分流,只让内网IP和指定测试手机号的用户走新支付逻辑:
# nginx.conf geo $wechat_pay_new { default 0; 192.168.1.0/24 1; # 内网全部放行 127.0.0.1 1; # 测试手机号对应的IP段(由运营提供) 203.208.60.0/24 1; } location /wechat/notify.php { if ($wechat_pay_new = 1) { fastcgi_pass php74; # 走新逻辑 } if ($wechat_pay_new = 0) { # 走老逻辑(如有)或返回维护页 return 503; } }血泪教训:某次上线没做灰度,凌晨2点突然大量
401报警,才发现是服务器时钟漂移超过5分钟(微信要求时间戳误差<5分钟),$timestamp全失效。从那以后我每次部署支付服务,都强制执行ntpdate -s time.windows.com,并在代码里加abs(time() - $timestamp) > 300校验,超时直接拒绝。希望帮到你。
本文还有配套的精品资源,点击获取