简介:这份资源是面向PHP开发者的微信支付V3完整实例,适合需要为线上商城或线下场景接入微信支付、希望掌握V3新接口安全机制的初中级开发者。压缩包共16个文件,约61KB,以asp与php脚本为主,辅以txt说明、pem证书、mdb数据文件、js脚本及gif图片,覆盖统一下单、前端调起支付、异步回调通知、订单查询确认等完整链路,并涉及API签名、证书管理、沙箱测试、异常处理与退款等关键环节。其中pem证书与配置脚本可用于理解商户私钥、公钥的加载与签名验证流程,说明文档则帮助快速理清目录结构与调用顺序。目前已有4630人学习下载,读者可借助其中的代码示例与配置文件,对照梳理V3支付从下单到回调的落地思路,并参考安全与合规注意事项,减少接入过程中的试错成本。
1. PHP 微信支付v3 完整实例:从下单到回调,一次跑通全链路
很多 PHP 项目接微信支付,卡在 v3 版本上:文档看了一遍,签名验签还是报 401;本地用 cURL 调通了,一上宝塔 PHP 环境就 500;回调地址配好了,异步通知却一直没进来。微信支付 v3 相比 v2,最大的变化是全面改用 JSON 请求体、SHA256-RSA 签名、AES-256-GCM 解密回调,证书也从单一的 API 密钥换成了商户私钥加平台证书。这套机制本身不复杂,但 PHP 生态里现成的完整实例偏少,很多人只能对着官方文档一段段拼。
这篇笔记就围绕「PHP 微信支付v3 完整实例」这个目标,把 JSAPI 下单、签名生成、回调验签解密、订单查询这几步串成一条能直接复现的链路。适合正在用 PHP 8 做商城、知识付费、会员充值这类需要微信支付的开发者,也适合从 v2 迁移过来、被签名和证书绕晕的熟手。下面所有代码都基于 PHP 8 + OpenSSL 扩展,不依赖官方 SDK,方便你理解每一步到底在干什么。
2. 微信支付v3 的签名与证书:先把黑匣子拆开
2.1 为什么 v3 的签名总报 401
v2 时代签名用的是 MD5 或 HMAC-SHA256,密钥就是那串 32 位的 API 密钥,拼参数排序后算个哈希就行。v3 换成了非对称加密:你用商户私钥对请求做 SHA256-RSA 签名,微信平台用你上传的公钥验签;反过来微信返回的数据,用平台私钥签名,你要用平台证书里的公钥验签。401 报错绝大多数不是代码写错,而是签名串拼错了。
v3 的签名串有固定格式,五行,每行以\n结尾,最后一行也要有:
HTTP请求方法\n URL路径\n 请求时间戳\n 随机字符串\n 请求报文主体\n注意几个细节:URL 路径要带 query string,比如/v3/pay/transactions/jsapi不带域名;请求时间戳是秒级;请求报文主体对 GET 请求是空字符串,但那个\n不能省。我见过太多人栽在最后一行没换行,或者 GET 请求把 body 写成了null字符串。
2.2 商户私钥、证书序列号、APIv3 密钥三件套怎么准备
在微信商户平台「账户中心 - API 安全」里,你需要拿到三样东西:
| 材料 | 用途 | 存放建议 |
|---|---|---|
| 商户 API 私钥 apiclient_key.pem | 请求签名 | 放在项目外目录,权限 600 |
| 商户证书序列号 | 请求头 Authorization 里标识用哪把钥匙 | 从 apiclient_cert.pem 里读,或平台直接看 |
| APIv3 密钥 | 解密回调里的敏感字段 | 32 位,自己设置的,别和 API 密钥搞混 |
商户私钥和证书是一对,用官方工具生成后会给你apiclient_key.pem和apiclient_cert.pem。序列号可以用这条命令读出来:
openssl x509 -in apiclient_cert.pem -noout -serial | awk -F= '{print $2}'读出来是一串十六进制,注意大小写要和平台显示一致,通常是大写。APIv3 密钥是你在商户平台单独设置的 32 位字符串,只用于 AES-256-GCM 解密,不参与签名。这三样东西搞混,是新手最常见的翻车点。
2.3 用 PHP 8 生成一次合法签名
下面这段代码是签名的最小实现,不依赖任何 SDK:
<?php // 商户私钥路径、证书序列号、APIv3密钥 $mchPrivateKeyPath = '/secure/apiclient_key.pem'; $mchSerialNo = '4A3B...'; // 你的证书序列号 $apiV3Key = 'your32charapiv3key000000000000'; /** * 生成 v3 请求签名 * @param string $method HTTP 方法,大写 * @param string $urlPath 带 query 的路径,如 /v3/pay/transactions/jsapi * @param string $body 请求体 JSON 字符串,GET 传空串 */ function buildAuthorization(string $method, string $urlPath, string $body): string { $mchPrivateKeyPath = '/secure/apiclient_key.pem'; $mchSerialNo = '4A3B...'; $timestamp = time(); $nonce = bin2hex(random_bytes(16)); // 32 位随机串 // 拼签名串,五行,每行都以 \n 结尾 $message = $method . "\n" . $urlPath . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; // 读取私钥并签名 $privateKey = openssl_pkey_get_private(file_get_contents($mchPrivateKeyPath)); openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); $sign = base64_encode($signature); // 拼 Authorization 头 return sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"', '你的商户号', $nonce, $sign, $timestamp, $mchSerialNo ); }逻辑说明:$message的拼接顺序和换行是微信规定的,错一个字符验签就失败。random_bytes(16)生成 16 字节再转十六进制,正好 32 位,符合 nonce_str 要求。openssl_sign用 SHA256 算法,对应头里的SHA256-RSA2048。参数上,$body必须是最终发送的原始 JSON 字符串,不能是数组再json_encode一次,否则空格和转义会不一致。
提示:私钥文件用
file_get_contents读进来即可,不要用openssl_pkey_get_private直接传路径,PHP 8 下传路径在某些环境会失败。
3. JSAPI 下单完整实例:从组装请求到拿到 prepay_id
3.1 下单接口的请求体字段怎么填
JSAPI 下单接口是POST /v3/pay/transactions/jsapi,请求体是 JSON。核心字段如下:
| 字段 | 必填 | 说明 |
|---|---|---|
| appid | 是 | 公众号或小程序的 appid |
| mchid | 是 | 商户号 |
| description | 是 | 商品描述,会显示在用户账单 |
| out_trade_no | 是 | 商户订单号,自己生成,唯一 |
| notify_url | 是 | 回调地址,必须 HTTPS,不能带参数 |
| amount.total | 是 | 金额,单位分,整数 |
| payer.openid | 是 | 用户 openid,JSAPI 必填 |
| scene_info | 否 | 场景信息,PC 网站支付需要 |
金额单位是分,这点和 v2 一样,别写成元。out_trade_no建议用「业务前缀 + 时间戳 + 随机数」,长度 6 到 32 位,只允许数字、字母、下划线、横线。
3.2 组装请求并拿到 prepay_id
<?php function jsapiOrder(string $openid, int $totalFee, string $outTradeNo): array { $urlPath = '/v3/pay/transactions/jsapi'; $bodyArr = [ 'appid' => 'wx1234567890abcdef', 'mchid' => '1900000001', 'description' => '会员充值-月度', 'out_trade_no' => $outTradeNo, 'notify_url' => 'https://yourdomain.com/notify.php', 'amount' => ['total' => $totalFee, 'currency' => 'CNY'], 'payer' => ['openid' => $openid], ]; $body = json_encode($bodyArr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $authorization = buildAuthorization('POST', $urlPath, $body); $ch = curl_init('https://api.mch.weixin.qq.com' . $urlPath); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: ' . $authorization, 'Content-Type: application/json', 'Accept: application/json', 'User-Agent: your-app/1.0', ], CURLOPT_TIMEOUT => 10, ]); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { throw new RuntimeException('下单失败: ' . $resp); } return json_decode($resp, true); // 含 prepay_id }逻辑说明:json_encode时加了JSON_UNESCAPED_UNICODE和JSON_UNESCAPED_SLASHES,保证中文和斜杠不被转义,这样签名用的 body 和实际发送的 body 完全一致。notify_url必须是 HTTPS 且不带 query,微信会校验。返回的prepay_id是下一步生成支付参数的关键。
参数上,totalFee是分,outTradeNo要保证全局唯一,重复下单会返回OUT_TRADE_NO_USED。CURLOPT_TIMEOUT设 10 秒,微信接口偶尔慢,但别设太长拖垮页面。
3.3 把 prepay_id 转成前端能调起的支付参数
拿到prepay_id后,还要再签一次名,生成paySign给前端WeixinJSBridge或wx.chooseWXPay用:
<?php function buildPayParams(string $prepayId): array { $appId = 'wx1234567890abcdef'; $timeStamp = (string)time(); $nonceStr = bin2hex(random_bytes(16)); $package = 'prepay_id=' . $prepayId; // 注意:这里签名串是四行,不是五行 $message = $appId . "\n" . $timeStamp . "\n" . $nonceStr . "\n" . $package . "\n"; $privateKey = openssl_pkey_get_private(file_get_contents('/secure/apiclient_key.pem')); openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); return [ 'appId' => $appId, 'timeStamp' => $timeStamp, 'nonceStr' => $nonceStr, 'package' => $package, 'signType' => 'RSA', 'paySign' => base64_encode($signature), ]; }逻辑说明:前端调起支付的签名串和请求接口的签名串不一样,这里是四行:appId、时间戳、随机串、prepay_id=xxx。很多人直接复用请求签名,结果前端报「支付签名验证失败」。signType固定RSA,不是 v2 的MD5。
注意:
timeStamp必须是字符串,前端拿到后原样传给微信,别转成数字,否则签名对不上。
4. 回调验签与解密:异步通知最容易翻车的地方
4.1 回调的验签流程和请求头
微信支付成功后,会向你的notify_url发一个 POST 请求,请求头里带Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial。验签要用平台证书的公钥,而不是商户自己的公钥。
验签串的拼法和请求签名类似,但顺序不同:
应答时间戳\n 应答随机串\n 应答报文主体\n注意这里没有 HTTP 方法和 URL 路径,只有三行。Wechatpay-Timestamp和Wechatpay-Nonce从请求头取,报文主体是原始 POST body。
4.2 用平台证书验签并解密 resource
平台证书需要先下载,或者用「获取平台证书」接口拉取。验签通过后,回调 body 里的resource是 AES-256-GCM 加密的,需要用 APIv3 密钥解密:
<?php function verifyAndDecrypt(string $body, array $headers, string $platformCertPath, string $apiV3Key): array { $timestamp = $headers['Wechatpay-Timestamp']; $nonce = $headers['Wechatpay-Nonce']; $signature = base64_decode($headers['Wechatpay-Signature']); // 拼验签串,三行 $message = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; // 用平台证书公钥验签 $pubKey = openssl_pkey_get_public(file_get_contents($platformCertPath)); $ok = openssl_verify($message, $signature, $pubKey, OPENSSL_ALGO_SHA256); if ($ok !== 1) { throw new RuntimeException('回调验签失败'); } // 解密 resource $data = json_decode($body, true); $ciphertext = base64_decode($data['resource']['ciphertext']); $nonceStr = $data['resource']['nonce']; $associatedData = $data['resource']['associated_data']; $plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonceStr, $associatedData ); if ($plaintext === false) { throw new RuntimeException('解密失败'); } return json_decode($plaintext, true); }逻辑说明:openssl_verify返回 1 才算验签通过,返回 0 是签名不匹配,返回 -1 是出错。AES-256-GCM 解密时,$nonceStr是 12 字节的 IV,$associatedData是附加数据,两者都从resource里取,不能自己编。$apiV3Key必须正好 32 字节,短了或长了都会解密失败。
参数上,$body必须是原始 POST 数据,用file_get_contents('php://input')读,不能用$_POST,因为$_POST会做 URL 解码,破坏原始报文。
4.3 回调里必须做的幂等和应答
解密后拿到的是支付结果,里面有out_trade_no、transaction_id、trade_state。处理逻辑要注意两点:
第一,幂等。微信会重复推送回调,直到你返回成功。所以要先查订单状态,已处理过的直接返回成功,别重复发货。
第二,应答格式。成功返回 HTTP 200,body 是{"code":"SUCCESS","message":"成功"};失败返回非 200,body 里带code和message,微信会按策略重试。
<?php // 回调入口 notify.php $body = file_get_contents('php://input'); $headers = array_change_key_case(getallheaders(), CASE_LOWER); try { $result = verifyAndDecrypt($body, $headers, '/secure/wechat_platform_cert.pem', $apiV3Key); // 幂等检查 if (orderAlreadyPaid($result['out_trade_no'])) { echo json_encode(['code' => 'SUCCESS', 'message' => '成功']); exit; } // 处理业务:更新订单、发货 handlePaidOrder($result); echo json_encode(['code' => 'SUCCESS', 'message' => '成功']); } catch (Throwable $e) { http_response_code(500); echo json_encode(['code' => 'FAIL', 'message' => $e->getMessage()]); }逻辑说明:getallheaders()在部分 PHP-FPM 环境可能不存在,可以用$_SERVER里HTTP_WECHATPAY_*手动拼。幂等检查建议用数据库唯一索引兜底,别只靠代码判断,并发下会漏。
5. 订单查询与退款:把状态对账做扎实
5.1 主动查询订单状态
回调可能因为网络问题丢失,所以要有主动查询兜底。查询接口是GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid=xxx:
<?php function queryOrder(string $outTradeNo, string $mchid): array { $urlPath = '/v3/pay/transactions/out-trade-no/' . $outTradeNo . '?mchid=' . $mchid; $authorization = buildAuthorization('GET', $urlPath, ''); // GET body 传空串 $ch = curl_init('https://api.mch.weixin.qq.com' . $urlPath); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: ' . $authorization, 'Accept: application/json', ], CURLOPT_TIMEOUT => 10, ]); $resp = curl_exec($ch); curl_close($ch); return json_decode($resp, true); }逻辑说明:GET 请求签名时 body 传空字符串,但签名串里那个\n不能省。urlPath要带 query string,因为签名串里的 URL 路径包含 query。返回的trade_state有SUCCESS、REFUND、NOTPAY、CLOSED等,只有SUCCESS才是支付成功。
参数上,out_trade_no和下单时一致,mchid是商户号。查询频率别太高,建议订单创建后 5 秒查一次,最多查 3 次,之后靠回调。
5.2 退款接口的签名和回调
退款是POST /v3/refund/domestic/refunds,请求体里out_trade_no和out_refund_no二选一,amount里refund是退款金额,total是原订单金额,单位都是分:
<?php function refund(string $outTradeNo, int $refundFee, int $totalFee, string $outRefundNo): array { $urlPath = '/v3/refund/domestic/refunds'; $body = json_encode([ 'out_trade_no' => $outTradeNo, 'out_refund_no' => $outRefundNo, 'amount' => ['refund' => $refundFee, 'total' => $totalFee, 'currency' => 'CNY'], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $authorization = buildAuthorization('POST', $urlPath, $body); // 后续 cURL 同下单,略 return []; }逻辑说明:退款金额不能大于原订单金额,out_refund_no也要唯一。退款结果也是异步回调,回调地址在商户平台配置,验签解密流程和支付回调一样,只是resource里的字段不同。
参数上,refundFee和totalFee都是分,totalFee要和原订单一致,否则会报PARAM_ERROR。
6. 避坑与排查:那些让我熬夜的报错
6.1 签名报 401 或 SIGN_ERROR
现象:调接口返回{"code":"SIGN_ERROR","message":"签名错误"}或 HTTP 401。
原因:签名串拼接错误,最常见的是 GET 请求 body 没传空串、最后一行没换行、URL 路径没带 query、或者json_encode后又被转义了一次。
解决:把签名串打印出来,逐行对比。特别注意\n的位置,用var_dump看字符串里是不是真的有换行。GET 请求的 body 传'',不是null。
6.2 回调验签失败
现象:回调进来后openssl_verify返回 0。
原因:平台证书不对,或者验签串拼错。平台证书要用「获取平台证书」接口拉取,不能拿商户证书当平台证书。验签串是三行,不是五行。
解决:先确认Wechatpay-Serial对应的平台证书是否正确,再检查验签串。注意$body必须是原始报文,用php://input读,别用$_POST。
6.3 解密报错或返回 false
现象:openssl_decrypt返回 false。
原因:APIv3 密钥长度不对,或者nonce、associated_data取错。APIv3 密钥必须正好 32 字节,nonce是 12 字节。
解决:检查 APIv3 密钥是不是 32 位,别把 API 密钥当 APIv3 密钥用。nonce和associated_data从resource里取,别自己生成。
6.4 回调一直没进来
现象:支付成功但notify_url没收到请求。
原因:notify_url不是 HTTPS,或者带了 query 参数,或者服务器防火墙拦了微信的 IP。
解决:notify_url必须 HTTPS 且不带参数,域名要能公网访问。检查服务器日志,看有没有微信的请求进来。如果用了宝塔,注意 PHP 版本和 OpenSSL 扩展是否开启。
6.5 金额单位写错
现象:下单成功但金额不对,或者报PARAM_ERROR。
原因:金额单位是分,写成了元。
解决:所有金额字段统一用分,前端传元的话在 PHP 里乘 100 再取整。别用浮点数,用整数。
7. 平台证书自动更新与本地调试技巧
平台证书有有效期,微信会定期更换。手动下载证书迟早会过期,所以生产环境要做自动更新。思路是:启动时或定时调用GET /v3/certificates拉取证书列表,用 APIv3 密钥解密每个证书的encrypt_certificate,拿到 PEM 格式存到本地,并记录序列号。回调验签时根据Wechatpay-Serial找到对应证书。
<?php function refreshPlatformCerts(string $apiV3Key): void { $urlPath = '/v3/certificates'; $authorization = buildAuthorization('GET', $urlPath, ''); // cURL 请求略,拿到 $resp $data = json_decode($resp, true); foreach ($data['data'] as $item) { $ciphertext = base64_decode($item['encrypt_certificate']['ciphertext']); $nonce = $item['encrypt_certificate']['nonce']; $ad = $item['encrypt_certificate']['associated_data']; $pem = openssl_decrypt($ciphertext, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $ad); // 存到 /secure/certs/{serial_no}.pem file_put_contents('/secure/certs/' . $item['serial_no'] . '.pem', $pem); } }逻辑说明:encrypt_certificate里的ciphertext解密后就是 PEM 格式的证书,直接存文件。序列号作为文件名,回调时按Wechatpay-Serial取。建议每天凌晨跑一次,或者每次验签失败时触发更新。
本地调试时,微信回调进不来,可以用「查单」接口模拟。把out_trade_no填进去,看返回的trade_state是不是SUCCESS。另外,微信支付有沙箱环境,但 v3 的沙箱和正式环境差异较大,建议直接用 1 分钱真实下单测试,回调地址用内网穿透工具映射到本地。调试时把curl的CURLOPT_VERBOSE打开,能看到完整的请求和响应头,排查签名问题很管用。
我自己踩过最深的坑是回调验签:一开始拿商户证书当平台证书用,验签一直失败,查了两天才发现证书用错了。后来养成习惯,所有证书文件按用途命名,mch_开头是商户的,plat_开头是平台的,再也没混过。希望帮到你。
本文还有配套的精品资源,点击获取