news 2026/10/6 9:04:11

PHP微信支付v3完整实例:从下单到回调全链路实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PHP微信支付v3完整实例:从下单到回调全链路实战

简介:这份资源是面向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_开头是平台的,再也没混过。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 9:03:21

SQL每日一题:从去重到慢查询优化的实战复盘指南

好的&#xff0c;遵照您的要求&#xff0c;我将仅依据提供的项目标题“sql每日一题”及相关关键词&#xff0c;撰写一篇符合所有规范的、直接可发布的Markdown格式博文。内容将完全围绕SQL学习与实操展开&#xff0c;不含任何违禁及敏感信息。 1. 为什么我坚持做“SQL每日一题…

作者头像 李华
网站建设 2026/10/6 9:02:58

video-scroll:滚动即播停滚即停的轻量实现与避坑指南

简介&#xff1a;这份资源是一套用于滚动开始与停止视频播放的轻量级 JavaScript 实现&#xff0c;面向需要为网页添加滚动触发视频控制逻辑的前端开发者&#xff0c;尤其适合刚接触 jQuery 与 DOM 事件、希望快速上手交互效果的入门与中级学习者。压缩包共 3 个文件&#xff0…

作者头像 李华
网站建设 2026/10/6 9:02:29

Anolis 8 静默安装 Oracle 11g 实战:依赖、内核参数与避坑指南

简介&#xff1a;这份资源面向需要在龙蜥Anolis操作系统上部署Oracle 11g数据库的运维与DBA人员&#xff0c;提供了一套可直接落地的安装与恢复方案。Anolis OS作为阿里云维护的企业级Linux发行版&#xff0c;是运行Oracle数据库的稳定基础环境&#xff0c;而该包通过自动化脚本…

作者头像 李华
网站建设 2026/10/6 9:01:54

YashanDB性能评估指南:6大核心指标与压测方法

YashanDB最近在国产基础软件圈子里存在感不低&#xff0c;厂商宣发材料里常出现“性能比肩国际主流数据库”这种话。但数据库选型这件事&#xff0c;光看PPT和跑分广告没有用&#xff0c;任何库到了手上都要先搭压测环境、把核心性能指标跑一遍&#xff0c;再决定能不能上生产。…

作者头像 李华
网站建设 2026/10/6 9:01:01

阿拉伯文HTML/CSS模板实战:RTL布局从入门到避坑

简介&#xff1a;这是一份面向阿拉伯语网站开发场景的 HTML 与 CSS 基础模板&#xff0c;适合需要快速搭建 RTL&#xff08;从右到左&#xff09;排版页面的前端初学者与开发者使用。模板在布局与样式上兼顾阿拉伯文的书写方向、文本对齐及文化审美习惯&#xff0c;可帮助不熟悉…

作者头像 李华
网站建设 2026/10/6 9:00:55

Maven实战指南:从安装配置到依赖管理与问题排查

1. Maven到底是干嘛的&#xff0c;为什么Java开发绕不开它先聊一个很多人刚入行时都会问的问题&#xff1a;Maven是干嘛的&#xff1f;网上搜出来的解释十有八九是"项目管理和构建自动化工具"&#xff0c;听起来很正式&#xff0c;但对新手来说等于没讲。我换个说法&…

作者头像 李华