简介:本资源是面向PHP后端开发者与微信支付接入实践者的V3版完整集成实例,聚焦最新微信支付接口的落地实现,解决商户系统快速对接、安全签名、证书管理及异步回调处理等核心难点。压缩包共16个文件(61KB),涵盖3个关键PHP业务文件(如payapi.php、sslapi.php)、7个ASP辅助类(含Class.asp、md5.asp等兼容性封装)、2个说明文档(txt)、1个PEM证书文件(ABCDEF.pem)及前端配套资源(js、gif、demo目录),结构清晰,兼顾服务端逻辑与轻量前端调用。已有4664人学习下载,适合中初级PHP工程师在真实项目中复用下单、通知验签、沙箱调试与异常退款等全流程代码模块,尤其可直接参考pemapi.php的证书加载机制、notify.asp的回调解析逻辑及Config.asp的密钥配置范式,大幅降低V3版本迁移门槛。
1. 一个能直接跑通、改完就能上线的微信支付v3 PHP落地包
你手头有个PHP项目,刚做完商品页和购物车,正准备接入微信支付——结果翻完微信官方文档,发现V3版要搞证书、验签、AES-GCM解密、平台证书自动更新、回调验签链路绕得像迷宫,连「获取预支付交易会话ID」这一步都要先拼接字符串再用私钥签名,还要求时间戳精确到秒、随机字符串32位、签名串必须URL安全Base64……更玄学的是,本地调试时微信回调总不触发,日志里只有一行401 Unauthorized,查半天才发现是平台证书没加载成功,而错误提示压根不告诉你缺哪张证书。这个「PHP微信支付v3 完整实例」就是为这种场景生的:它不是教学Demo,不省略任何生产级细节;它带完整目录结构、可执行的pay.php和notify.php、自动生成并热加载平台证书的守护脚本、预置好curl_setopt全参数的HTTP客户端封装、以及最关键的——所有签名/验签逻辑都已抽成独立方法,参数名和微信文档字段完全对齐。适合正在赶工期的中小项目后端、需要快速交付支付模块的外包开发者,或想彻底搞懂V3签名链路的PHP中级工程师。它不讲OAuth2原理,但让你明天就能把「微信支付」按钮点出真实订单。
2. 技术栈与工程结构:为什么选cURL+原生PHP而不是SDK?
微信官方虽提供PHP SDK,但实际项目中我见过太多团队踩坑:SDK版本滞后(比如V3接口已支持分账子商户号,SDK却还卡在旧版)、证书管理黑盒化(你根本不知道它何时去拉新证书、失败后是否降级重试)、异常堆栈不透出真实HTTP状态码(400 Bad Request被吞成InvalidRequestException,但没告诉你具体哪个字段格式错)。这个实例反其道而行——全部手写核心链路,仅依赖PHP 7.4+原生扩展(openssl、json、mbstring)和cURL,好处是:每行代码你都能打断点、每个HTTP请求你能看到原始header/body、每次签名你能打印出待签名字符串明文。下面拆解它的物理结构:
wechat-pay-v3/ ├── config/ │ ├── app.php # 商户基础配置:商户号、APIv3密钥、证书路径 │ └── cert/ # 证书目录(初始为空,首次运行自动生成) │ ├── apiclient_cert.pem # 商户API证书(需自行生成并放入) │ ├── apiclient_key.pem # 商户API私钥(需自行生成并放入) │ └── wechatpay_cert.pem # 微信平台证书(首次调用自动下载) ├── core/ │ ├── HttpClient.php # 封装cURL:自动设置SSL证书、超时、重试、Header签名 │ ├── Signer.php # 核心签名类:generateSign() / verifySign() │ └── AesGcm.php # AES-GCM解密工具(处理回调通知中的加密body) ├── service/ │ ├── PayService.php # 支付主服务:统一下单、查询订单、关闭订单 │ └── NotifyService.php # 回调处理服务:验签、解密、业务逻辑钩子 ├── public/ │ ├── pay.php # 前端JS调用入口:返回prepay_id等参数给wx.requestPayment │ ├── notify.php # 微信服务器回调入口:接收并处理支付结果 │ └── cert-refresh.php # 手动刷新平台证书(开发期调试用) ├── vendor/ # 空目录(不引入Composer依赖,避免autoload冲突) └── index.php # 入口文件(仅做路由转发,无业务逻辑)提示:
apiclient_cert.pem和apiclient_key.pem需从微信商户平台「API安全」页下载并转换格式(.p12转.pem),命令见下文;wechatpay_cert.pem由程序首次调用cert-refresh.php自动生成,无需手动操作。
2.1 为什么坚持手写HttpClient而非用Guzzle?
Guzzle虽强大,但在微信V3场景下有三处硬伤:
- 证书路径绑定死:Guzzle默认用系统CA,而微信平台证书必须显式指定
CURLOPT_SSLCERT和CURLOPT_SSLKEY,且每次请求都要传商户证书; - Header签名动态性差:微信要求每个请求Header含
Authorization: WECHATPAY2-SHA256-RSA2048 ...,其中包含时间戳、随机串、签名值,Guzzle中间件难以干净注入; - 错误处理粒度粗:
400 Bad Request时Guzzle抛ClientException,但微信V3的错误响应体含详细code和message(如INVALID_REQUEST/MCH_NOT_MATCH),需原生curl_error()+curl_getinfo($ch, CURLINFO_HTTP_CODE)才能捕获。
所以本实例的core/HttpClient.php直接封装cURL,关键参数如下:
// core/HttpClient.php 片段 public function request(string $method, string $url, array $options = []): array { $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSLCERT => $this->config['cert_path'] . '/apiclient_cert.pem', CURLOPT_SSLKEY => $this->config['cert_path'] . '/apiclient_key.pem', CURLOPT_CAINFO => $this->config['cert_path'] . '/wechatpay_cert.pem', // 关键!必须用平台证书验微信域名 CURLOPT_HTTPHEADER => $this->buildHeaders($method, $url, $options['body'] ?? ''), CURLOPT_POSTFIELDS => $options['body'] ?? '', ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); return [ 'code' => $httpCode, 'body' => $response, 'error' => $error, 'headers' => $this->parseHeaders(curl_getinfo($ch, CURLINFO_HEADER_OUT)), ]; }buildHeaders()方法负责生成微信要求的Authorization头,它调用Signer::generateSign(),传入$method、$url、$timestamp、$nonceStr、$body五元组——这正是微信文档《APIv3签名验证》章节定义的标准签名流程。
2.2 平台证书自动管理:为什么不能放Git里?
微信平台证书有效期90天,且可能因安全事件提前吊销。若硬编码证书到代码库,一旦过期将导致所有支付请求401 Unauthorized,而错误日志只显示SSL certificate problem: unable to get local issuer certificate,新手极易误判为Nginx配置问题。本实例采用「懒加载+自动刷新」策略:
- 首次调用
PayService::unifiedOrder()时,检查cert/wechatpay_cert.pem是否存在且未过期(读取openssl x509 -in cert.pem -enddate -noout); - 若不存在或过期,则调用
cert-refresh.php(内部走GET https://api.mch.weixin.qq.com/v3/certificates),用商户私钥解密响应体中的加密证书; - 解密后写入文件,并更新
filemtime()供下次校验。
core/Signer.php中证书解密逻辑如下:
// core/Signer.php 片段:解密平台证书响应体 public function decryptCertificate(string $encrypted: string, string $associatedData: string, string $nonce: string): string { $key = $this->getMerchantPrivateKey(); // 从apiclient_key.pem读取 $ciphertext = base64_decode($encrypted); $tag = substr($ciphertext, -16); // GCM tag固定16字节 $ciphertext = substr($ciphertext, 0, -16); $plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, $associatedData ); if ($plaintext === false) { throw new \RuntimeException('AES-GCM decrypt failed: ' . openssl_error_string()); } return $plaintext; }注意$associatedData必须为"certificate"(微信硬编码),$nonce为响应头Wechatpay-Nonce值——漏掉任一参数都会解密失败,这是新手最常翻车的点。
2.3 目录权限与部署红线
生产环境必须严格限制证书目录权限,否则私钥泄露=商户资金失控。实测部署时需执行:
# 进入项目根目录 chmod 750 config/cert/ chmod 640 config/cert/apiclient_key.pem chmod 644 config/cert/apiclient_cert.pem chmod 644 config/cert/wechatpay_cert.pem chown -R www-data:www-data config/cert/ # 假设Web服务器用户为www-data注意:
apiclient_key.pem权限绝不能是644!曾有团队因FTP上传时自动赋权644,导致私钥被恶意爬虫扫出,三天内被盗刷数万元。从那以后我每次部署都强制走一遍ls -l config/cert/校验。
3. 核心支付流程:从下单到回调的完整链路
微信支付V3的「统一下单」不是简单POST一个JSON,而是涉及四层签名嵌套:商户私钥签名请求体 → 微信用平台公钥验签 → 微信返回预支付ID → 前端调起wx.requestPayment→ 微信异步回调加密结果 → 你用平台证书验签 + AES-GCM解密。本实例将这四层拆解为可调试的独立方法,下面以public/pay.php为起点逐层解析。
3.1pay.php:生成prepay_id并返回给前端
这是前端JS调用的唯一入口,接收GET参数amount(分)、description(商品描述),返回JSON格式的wx.requestPayment所需参数:
// public/pay.php require_once __DIR__ . '/../vendor/autoload.php'; use Service\PayService; try { $amount = (int)$_GET['amount'] ?? 1; $description = $_GET['description'] ?? '测试商品'; $payService = new PayService(); $result = $payService->unifiedOrder([ 'amount' => ['total' => $amount, 'currency' => 'CNY'], 'description' => $description, 'payer' => ['openid' => 'oUpF8uMuAJO_M29ayb0Zm2qI0WUc'], // 测试用固定openid 'notify_url' => 'https://your-domain.com/notify.php', // 必须是HTTPS且备案域名 ]); // 构造wx.requestPayment参数 $timeStamp = (string)time(); $nonceStr = bin2hex(random_bytes(16)); $package = 'prepay_id=' . $result['prepay_id']; $signer = new Core\Signer(); $paySign = $signer->generateSign('POST', '/v3/pay/transactions/jsapi', $timeStamp, $nonceStr, $package); echo json_encode([ 'appId' => 'wx8888888888888888', 'timeStamp' => $timeStamp, 'nonceStr' => $nonceStr, 'package' => $package, 'signType' => 'RSA-SHA256', 'paySign' => $paySign, ]); } catch (\Exception $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); }关键点在于$signer->generateSign()的四个参数:
$method:必须是POST(微信V3规定);$url:必须是/v3/pay/transactions/jsapi(注意不是旧版/pay/unifiedorder);$timeStamp:当前Unix时间戳,微信要求与服务器时间误差≤300秒;$package:固定格式prepay_id=wx123...,不能加空格、不能URL编码。
3.2PayService::unifiedOrder():V3统一下单核心实现
该方法封装了完整的HTTP请求+签名+错误处理,是整个支付链路的中枢:
// service/PayService.php 片段 public function unifiedOrder(array $params): array { $url = 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi'; $body = json_encode([ 'mchid' => $this->config['mchid'], 'appid' => $this->config['appid'], 'description' => $params['description'], 'out_trade_no' => date('YmdHis') . rand(1000, 9999), // 商户订单号 'notify_url' => $params['notify_url'], 'amount' => $params['amount'], 'payer' => $params['payer'], ], JSON_UNESCAPED_UNICODE); $httpClient = new Core\HttpClient($this->config); $response = $httpClient->request('POST', $url, ['body' => $body]); if ($response['code'] !== 200) { $this->logError("UnifiedOrder failed: {$response['code']} {$response['error']}"); $this->logError("Response body: {$response['body']}"); throw new \RuntimeException("WeChat API error: {$response['body']}"); } $data = json_decode($response['body'], true); if (!isset($data['prepay_id'])) { throw new \RuntimeException('Missing prepay_id in response'); } return $data; // 返回包含prepay_id的完整响应 }注意$body中out_trade_no必须全局唯一,建议用date('YmdHis').rand(1000,9999)生成(避免纯时间戳在高并发下重复);notify_url必须是HTTPS且通过ICP备案,否则微信回调永远不触发——这是新手第二高发问题。
3.3notify.php:安全处理微信异步回调
微信支付成功后,会向notify_url发起POST请求,但Body是AES-GCM加密的,Header含Wechatpay-Serial,Wechatpay-Timestamp,Wechatpay-Nonce,Wechatpay-Signature四字段。notify.php必须:
- 从Header提取四元组;
- 用
Signer::verifySign()验证签名; - 用
AesGcm::decrypt()解密Body; - 解析JSON并执行业务逻辑(如更新订单状态);
- 返回200空响应(微信要求,否则持续重试)。
// public/notify.php require_once __DIR__ . '/../vendor/autoload.php'; use Service\NotifyService; $notifyService = new NotifyService(); $result = $notifyService->handle(); if ($result['success']) { http_response_code(200); echo '{"code":"SUCCESS","message":"OK"}'; // 微信要求固定格式 } else { http_response_code(500); echo json_encode(['error' => $result['message']]); }NotifyService::handle()内部逻辑:
// service/NotifyService.php 片段 public function handle(): array { $rawBody = file_get_contents('php://input'); $headers = $this->getHeaders(); // 从$_SERVER提取Wechatpay-*头 // 步骤1:验签 if (!$this->signer->verifySign( $headers['Wechatpay-Serial'] ?? '', $headers['Wechatpay-Timestamp'] ?? '', $headers['Wechatpay-Nonce'] ?? '', $headers['Wechatpay-Signature'] ?? '', $rawBody )) { return ['success' => false, 'message' => 'Signature verification failed']; } // 步骤2:解密 $decrypted = $this->aesGcm->decrypt( $rawBody, $headers['Wechatpay-Associated-Data'] ?? 'transaction', $headers['Wechatpay-Nonce'] ?? '' ); $data = json_decode($decrypted, true); if (!$data || !isset($data['resource']['cipher_text'])) { return ['success' => false, 'message' => 'Invalid encrypted data']; } // 步骤3:二次解密(resource.cipher_text才是最终支付结果) $plainText = $this->aesGcm->decrypt( $data['resource']['cipher_text'], $data['resource']['associated_data'] ?? 'transaction', $data['resource']['nonce'] ?? '' ); $payResult = json_decode($plainText, true); if ($payResult['event_type'] !== 'TRANSACTION.SUCCESS') { return ['success' => false, 'message' => 'Not success event']; } // 步骤4:业务处理(此处应调用你的订单服务) $this->updateOrderStatus($payResult['resource']['out_trade_no'], 'paid'); return ['success' => true, 'message' => 'OK']; }提示:
$data['resource']['associated_data']在V3文档中明确要求为"transaction",但实测部分微信环境会返回空字符串,故代码中用?? 'transaction'兜底——这是血泪经验,不加这行会导致解密失败。
4. 避坑指南:5个让开发者凌晨三点还在改的致命细节
微信支付V3的坑不在算法,而在文档模糊处和微信服务端的隐式规则。以下是我在三个项目中踩过的真问题,按「现象→原因→解决」整理,每条都附可复现的验证方式。
4.1 现象:curl_exec()返回false,curl_error()显示SSL certificate problem: self signed certificate in certificate chain
原因:CURLOPT_CAINFO指向的wechatpay_cert.pem不是微信平台证书,而是商户API证书(apiclient_cert.pem)。微信要求用平台证书验证其域名(api.mch.weixin.qq.com),而商户证书是用于证明你身份的,二者不可混用。
验证:在core/HttpClient.php中临时加一行var_dump($this->config['cert_path'] . '/wechatpay_cert.pem');,然后访问cert-refresh.php,确认生成的证书文件存在且非空。
解决:确保CURLOPT_CAINFO参数值为$this->config['cert_path'] . '/wechatpay_cert.pem',且该文件由cert-refresh.php生成(不是手动复制的旧证书)。
4.2 现象:notify.php收到请求但file_get_contents('php://input')为空
原因:PHP配置中enable_post_data_reading = Off,或Web服务器(如Nginx)配置了client_max_body_size过小,导致微信回调的加密Body被截断。
验证:在notify.php开头加file_put_contents('/tmp/notify-raw.log', print_r($_SERVER, true) . "\n" . file_get_contents('php://input'), FILE_APPEND);,查看日志中php://input是否为空。
解决:
- 检查
php.ini:enable_post_data_reading = On; - Nginx中
location ~ \.php$ { ... client_max_body_size 10M; }; - Apache中
<Directory>块加LimitRequestBody 10485760。
4.3 现象:generateSign()生成的签名总是Invalid signature
原因:待签名字符串中$body参数未用json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),导致中文被转义为\uXXXX,而微信验签时用的是原始UTF-8字节流。
验证:在Signer::generateSign()中echo "ToSign:\n{$method}\n{$url}\n{$timestamp}\n{$nonceStr}\n{$body}\n";,对比微信文档示例的待签名字符串(注意中文是否为原样)。
解决:统一使用json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)生成Body,禁用所有自动转义。
4.4 现象:cert-refresh.php报错openssl_decrypt(): IV length too short
原因:微信返回的nonce字段是Base64编码的12字节随机串,但openssl_decrypt()要求IV(即nonce)为原始二进制,需base64_decode()后再传入。
验证:在Signer::decryptCertificate()中var_dump($nonce, base64_decode($nonce));,确认base64_decode($nonce)长度为12。
解决:$nonce = base64_decode($nonce);必须在调用openssl_decrypt()前执行。
4.5 现象:pay.php返回prepay_id,但前端wx.requestPayment报fail invalid sign
原因:前端传入wx.requestPayment的timeStamp、nonceStr、package与后端生成paySign时用的三者不一致。常见于前端JS用new Date().getTime()(毫秒)而PHP用time()(秒),或前端生成nonceStr未用Math.random().toString(36).substr(2, 16)保证32位。
验证:在pay.php中echo "Debug: ts={$timeStamp}, nonce={$nonceStr}, pkg={$package}, sign={$paySign}";,前端console.log对应参数,逐字比对。
解决:前端timeStamp必须用Math.floor(Date.now()/1000),nonceStr用Array.from({length:32},()=>Math.random().toString(36)[2]).join('')生成。
5. 二次开发实战:如何快速接入分账、退款、查询订单
当你跑通基础支付后,业务必然延伸至分账(给供应商结算)、退款(用户申请退货)、订单查询(客服查状态)。这些接口在V3中结构高度一致,只需复用现有签名/验签/HTTP模块,本章给出可直接抄的改造模板。
5.1 分账:profit-sharing接口接入
分账需先在微信商户平台开通「分账」功能,并配置分账接收方(子商户或个人)。核心是调用POST /v3/profitsharing/orders,其签名逻辑与统一下单完全相同,区别仅在URL和Body字段:
// service/ProfitSharingService.php public function createOrder(string $transactionId, array $receivers): array { $url = 'https://api.mch.weixin.qq.com/v3/profitsharing/orders'; $body = json_encode([ 'transaction_id' => $transactionId, // 原支付的transaction_id 'receivers' => $receivers, // 分账接收方数组 'unfreeze_unsplit' => true, // 未分账金额是否解冻 ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $httpClient = new Core\HttpClient($this->config); $response = $httpClient->request('POST', $url, ['body' => $body]); if ($response['code'] !== 200) { throw new \RuntimeException("Profit sharing failed: {$response['body']}"); } return json_decode($response['body'], true); } // 调用示例 $service = new ProfitSharingService(); $result = $service->createOrder('42084507402014111112820824', [ [ 'type' => 'MERCHANT_ID', // 或PERSONAL_OPENID 'account' => '1900000109', // 子商户号或openid 'amount' => 100, 'description' => '分账给供应商' ] ]);注意:
receivers中type为MERCHANT_ID时,account填子商户号;为PERSONAL_OPENID时,account填用户openid(需用户授权auth_base作用域)。
5.2 退款:refunds接口安全改造
退款接口POST /v3/refund/domestic/refunds要求更高安全性:
- Body中
out_refund_no(商户退款单号)必须全局唯一; amount中refund字段必须≤原订单total;- 微信回调退款结果时,
event_type为REFUND.SUCCESS,但resource结构与支付回调不同。
// service/RefundService.php public function createRefund(string $transactionId, int $refundAmount, string $outRefundNo): array { $url = 'https://api.mch.weixin.qq.com/v3/refund/domestic/refunds'; $body = json_encode([ 'transaction_id' => $transactionId, 'out_refund_no' => $outRefundNo, 'amount' => [ 'refund' => $refundAmount, 'total' => $refundAmount, // V3要求refund=total ], 'reason' => '用户申请退货', ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $httpClient = new Core\HttpClient($this->config); $response = $httpClient->request('POST', $url, ['body' => $body]); if ($response['code'] !== 200) { throw new \RuntimeException("Refund failed: {$response['body']}"); } return json_decode($response['body'], true); }退款回调处理需单独写refund-notify.php,因为event_type不同,不能复用notify.php。核心差异在解密后解析:
// refund-notify.php 片段 $plainText = $this->aesGcm->decrypt($cipherText, 'refund', $nonce); $refundResult = json_decode($plainText, true); if ($refundResult['event_type'] === 'REFUND.SUCCESS') { $this->updateRefundStatus($refundResult['resource']['out_refund_no'], 'success'); }5.3 订单查询:transactions/id接口防抖设计
查询订单GET /v3/pay/transactions/id/{transaction_id}看似简单,但生产环境必须加防抖:
- 避免前端频繁轮询(如每秒查一次),应改为指数退避(1s→2s→4s→8s);
- 后端缓存查询结果(Redis存5分钟),相同
transaction_id10秒内只查一次微信; - 微信返回
status为SUCCESS/CLOSED/REFUND等,需映射到你的订单状态机。
// service/QueryService.php public function queryByTransactionId(string $transactionId): array { $cacheKey = 'wx_transaction_' . $transactionId; $cached = $this->redis->get($cacheKey); if ($cached) { return json_decode($cached, true); } $url = "https://api.mch.weixin.qq.com/v3/pay/transactions/id/{$transactionId}"; $httpClient = new Core\HttpClient($this->config); $response = $httpClient->request('GET', $url); if ($response['code'] !== 200) { throw new \RuntimeException("Query failed: {$response['body']}"); } $data = json_decode($response['body'], true); $this->redis->setex($cacheKey, 300, $response['body']); // 缓存5分钟 return $data; }从那以后我每次接入新支付能力,都先看微信文档URL路径是否以/v3/开头、Body是否要求JSON、Header是否含Authorization——只要满足这三点,就直接复制PayService的unifiedOrder()结构,改URL和参数名,90%的接口当天就能跑通。希望帮到你。
本文还有配套的精品资源,点击获取