ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP微信支付V3完整案例:从签名、回调到对账的落地实践

PHP微信支付V3完整案例:从签名、回调到对账的落地实践

简介:这是一份面向PHP开发者的微信支付V3接口接入案例,适合具备PHP面向对象基础、需要为项目快速集成微信支付能力的开发者参考。资源围绕V3版本核心支付流程展开,同时兼容V2部分功能,覆盖订单创建、支付验证、退款申请、支付结果异步通知与状态查询等关键环节,并涉及OAuth2.0授权获取OpenID、RSA2048证书加解密、日志记录与异常处理等安全规范,帮助降低接入复杂度。压缩包共15个文件,以14个PHP源码为主,辅以1个Markdown说明文档,整体约42KB,包含配置、核心支付类、JSAPI与Native扫码支付、订单与退款操作及示例代码等模块,结构清晰便于按需查阅。目前已有2012人学习下载,读者可借此理解完整支付链路、回调验证与退款查询的实现思路,并参考其封装方式与排错经验,快速应用到实际项目中。

1. 从一次对账翻车说起:PHP 微信支付 V3 到底该怎么落地

去年帮一个做知识付费的朋友排查问题,他的订单系统跑了大半年,财务月底对账时发现有三笔订单状态对不上——用户说付了,后台显示未支付,但微信商户平台里钱确实到账了。翻日志才发现,他用的是老版本 XML 接口,回调验签逻辑写得含糊,加上服务器偶尔超时,回调丢了也没重试机制。这件事让我意识到,PHP 微信支付 V3不是把官方 SDK 拉下来调两个方法就完事,它涉及签名、证书、回调验签、幂等处理一整条链路。

这份 PHP 微信支付 V3 案例方案,面向的是需要在自己项目里接入 JSAPI 支付、Native 扫码支付或 H5 支付的 PHP 开发者,尤其是做毕设或课程设计时需要一套完整可跑通代码的同学。方案覆盖从商户配置、下单、签名、回调到对账的完整闭环,下面按需求、设计、实现、避坑、验收的顺序拆开讲。

2. 需求拆解与技术选型:为什么 V3 和 V2 是两套逻辑

2.1 业务场景到底需要哪些支付能力

先别急着写代码,把需求列清楚。一个典型的 PHP 项目接入微信支付,通常要覆盖这几类场景:用户在公众号内点单走 JSAPI 支付,PC 端展示二维码走 Native 支付,手机浏览器走 H5 支付,退款走退款接口,另外还需要处理支付结果通知和查询订单状态。

这几类场景对技术的要求不一样。JSAPI 需要获取用户 openid,依赖公众号网页授权;Native 只需要生成二维码链接;H5 需要配置场景信息。但它们共用同一套下单和签名逻辑,所以设计上应该把公共部分抽出来,而不是每个场景复制一份代码。

从数据流角度看,一笔支付涉及四个角色:商户服务器、微信支付网关、用户客户端、商户后台。核心流程是:商户服务器调用统一下单接口拿到 prepay_id,前端用 prepay_id 唤起支付,用户完成支付后微信异步通知商户服务器,商户服务器验签后更新订单状态。这条链路里任何一环出问题,都会导致订单状态不一致。

2.2 V3 与 V2 的核心差异

很多同学搜到的老教程还是 V2 的 XML 接口,直接套用会踩坑。V3 和 V2 的差异不是版本号升级那么简单,而是整套通信机制的更换。

对比项V2V3
数据格式XMLJSON
签名算法MD5/HMAC-SHA256SHA256-RSA
认证方式签名签名 + 平台证书
回调验签MD5 验签平台证书验签 + AES 解密
接口地址api.mch.weixin.qq.comapi.mch.weixin.qq.com/v3/

V3 最大的变化是引入了平台证书机制。商户请求时用自己的私钥签名,微信返回的数据用平台证书签名,商户需要用微信的平台证书验签。回调通知里的敏感信息还用 AES-256-GCM 加密,需要 APIv3 密钥解密。这套机制安全性更高,但代码量也更大。

2.3 技术选型:自己封装还是用官方 SDK

微信官方提供了 PHP 版的 SDK(wechatpay-php),基于 Guzzle 封装。用官方 SDK 的好处是签名、验签、证书管理都帮你处理了,坏处是依赖较多,对 PHP 版本有要求(7.2+),而且出问题时排查链路长。

我的建议是:毕设或课程设计场景,用官方 SDK 快速跑通,把精力放在业务逻辑上;如果是生产项目且团队对依赖敏感,可以自己封装核心的签名和验签逻辑,只依赖 openssl 扩展和 curl。下面方案里我会给出自己封装的核心代码,因为这样你能真正理解每一步在做什么,答辩时也讲得清楚。

提示:无论用哪种方式,APIv3 密钥和商户私钥绝对不能硬编码在代码里,更不能提交到 Git 仓库。用环境变量或配置文件加载,配置文件加入 .gitignore。

3. 总体架构设计:四个模块撑起完整支付链路

3.1 模块划分与职责边界

整套方案拆成四个模块,每个模块职责单一,方便单独测试和替换。

配置模块负责加载商户号、APIv3 密钥、商户私钥、证书序列号等参数,对外提供统一的配置读取接口。签名模块负责请求签名和应答验签,是整套方案的核心。业务模块封装下单、查询、退款、关单等具体接口调用。回调模块处理微信异步通知,包括验签、解密、幂等更新订单状态。

这样划分的好处是,签名逻辑只写一次,所有接口复用;回调逻辑独立,方便加日志和重试。模块之间通过明确的输入输出交互,不互相依赖内部实现。

3.2 目录结构设计

project/ ├── config/ │ └── wxpay.php # 商户配置,从环境变量读取 ├── src/ │ ├── Signer.php # 签名与验签 │ ├── HttpClient.php # 带签名的 HTTP 请求封装 │ ├── PayService.php # 下单、查询、退款 │ └── NotifyHandler.php # 回调处理 ├── cert/ │ ├── apiclient_key.pem # 商户私钥 │ └── wechat_platform.pem # 平台证书 ├── logs/ │ └── wxpay.log # 支付日志 └── public/ └── notify.php # 回调入口

这个结构把配置、源码、证书、日志、入口分开,部署时只需要保证 cert 目录可读、logs 目录可写。证书文件权限设为 600,避免被其他进程读取。

3.3 核心数据流

一笔 JSAPI 支付的完整数据流是这样的:前端请求后端下单接口,后端组装参数调用微信统一下单,拿到 prepay_id 后返回给前端;前端用 prepay_id 和签名参数唤起微信支付;用户支付成功后,微信向 notify.php 发送加密通知;notify.php 验签解密后更新订单状态,返回成功应答;如果商户服务器没返回成功,微信会按策略重试。

这里有个关键设计点:下单和回调是两条独立的链路。下单成功不代表支付成功,只有收到回调并验签通过,才能认为支付完成。很多初学者在这里翻车,下单接口一返回就更新订单为已支付,结果用户根本没付款。

4. 核心模块实现:签名、下单、回调三段代码

4.1 请求签名与应答验签

V3 的签名规则是:构造签名串,用商户私钥做 SHA256-RSA 签名,Base64 编码后放入 Authorization 头。签名串的格式是固定的五行:

HTTP请求方法\n URL路径\n 请求时间戳\n 随机字符串\n 请求报文主体\n

下面是签名类的核心实现:

<?php class Signer { private string $mchId; private string $serialNo; private string $privateKey; public function __construct(string $mchId, string $serialNo, string $privateKeyPath) { $this->mchId = $mchId; $this->serialNo = $serialNo; // 读取商户私钥,注意权限控制 $this->privateKey = file_get_contents($privateKeyPath); } /** * 生成请求签名 * @param string $method HTTP方法,如POST * @param string $urlPath 接口路径,如/v3/pay/transactions/jsapi * @param string $body 请求体JSON字符串 */ public function sign(string $method, string $urlPath, string $body): string { $timestamp = time(); $nonce = bin2hex(random_bytes(16)); // 构造签名串,每行以\n结尾 $message = $method . "\n" . $urlPath . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; // SHA256-RSA 签名 openssl_sign($message, $signature, $this->privateKey, OPENSSL_ALGO_SHA256); $sign = base64_encode($signature); // 拼接 Authorization 头 return sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"', $this->mchId, $nonce, $sign, $timestamp, $this->serialNo ); } /** * 验证微信应答或回调的签名 * @param string $timestamp 应答头中的时间戳 * @param string $nonce 应答头中的随机串 * @param string $body 应答体 * @param string $signature 应答头中的签名 * @param string $platformCert 平台证书内容 */ public function verify(string $timestamp, string $nonce, string $body, string $signature, string $platformCert): bool { $message = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; $publicKey = openssl_pkey_get_public($platformCert); $result = openssl_verify($message, base64_decode($signature), $publicKey, OPENSSL_ALGO_SHA256); return $result === 1; } }

签名串的五行顺序不能错,每行末尾的\n不能少,这是最容易翻车的地方。serial_no是商户证书序列号,在商户平台可以查到。验签时用的是平台证书的公钥,不是商户自己的证书。

4.2 统一下单接口封装

下单接口需要根据支付场景传不同参数。JSAPI 必须传 payer.openid,Native 不需要,H5 需要传 scene_info。下面封装一个通用的下单方法:

<?php class PayService { private Signer $signer; private string $baseUrl = 'https://api.mch.weixin.qq.com'; public function __construct(Signer $signer) { $this->signer = $signer; } /** * JSAPI 下单 * @param array $order 订单信息 * @return array 包含 prepay_id 的应答 */ public function jsapiOrder(array $order): array { $urlPath = '/v3/pay/transactions/jsapi'; $body = json_encode([ 'appid' => $order['appid'], 'mchid' => $order['mchid'], 'description' => $order['description'], 'out_trade_no' => $order['out_trade_no'], 'notify_url' => $order['notify_url'], 'amount' => [ 'total' => $order['total'], // 单位:分 'currency' => 'CNY', ], 'payer' => [ 'openid' => $order['openid'], ], ], JSON_UNESCAPED_UNICODE); $auth = $this->signer->sign('POST', $urlPath, $body); return $this->request('POST', $this->baseUrl . $urlPath, $body, $auth); } /** * 带签名的 HTTP 请求 */ private function request(string $method, string $url, string $body, string $auth): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', 'Authorization: ' . $auth, ], CURLOPT_TIMEOUT => 10, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { // 记录错误日志,便于排查 error_log("wxpay error: code={$httpCode}, resp={$response}"); } return json_decode($response, true) ?? []; } }

out_trade_no是商户订单号,必须全局唯一,建议用「业务前缀 + 时间戳 + 随机数」生成。total单位是分,传 1 表示 1 分钱,别传成元。notify_url必须是公网可访问的 HTTPS 地址,本地开发可以用内网穿透工具临时映射。

4.3 回调验签与解密

回调处理是整套方案里最容易出问题的环节。微信发来的通知是加密的,需要先用平台证书验签,再用 APIv3 密钥做 AES-256-GCM 解密。

<?php class NotifyHandler { private string $apiV3Key; private Signer $signer; public function __construct(string $apiV3Key, Signer $signer) { $this->apiV3Key = $apiV3Key; $this->signer = $signer; } /** * 处理回调通知 * @param array $headers 请求头 * @param string $body 原始请求体 * @return array 解密后的订单数据 */ public function handle(array $headers, string $body): array { // 1. 验签 $verified = $this->signer->verify( $headers['Wechatpay-Timestamp'], $headers['Wechatpay-Nonce'], $body, $headers['Wechatpay-Signature'], file_get_contents(__DIR__ . '/../cert/wechat_platform.pem') ); if (!$verified) { throw new Exception('回调验签失败'); } // 2. 解析通知体 $data = json_decode($body, true); $resource = $data['resource']; // 3. AES-256-GCM 解密 $ciphertext = base64_decode($resource['ciphertext']); $nonce = $resource['nonce']; $associatedData = $resource['associated_data']; $tag = substr($ciphertext, -16); // 最后16字节是认证标签 $ciphertext = substr($ciphertext, 0, -16); $plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', $this->apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $associatedData ); if ($plaintext === false) { throw new Exception('回调解密失败'); } return json_decode($plaintext, true); } }

解密时tag是密文的最后 16 字节,需要先截取出来再解密,这个细节官方文档写得比较隐晦,很多人在这里卡住。解密后的数据里包含out_trade_no、transaction_id、trade_state等字段,trade_state为SUCCESS才表示支付成功。

拿到订单数据后,更新订单状态前一定要做幂等判断:先查订单当前状态,如果已经是已支付就直接返回成功,避免微信重试导致重复加钱或重复发货。

5. 避坑与常见问题排查:五个最容易翻车的点

5.1 签名失败:签名串格式错误

现象:调用接口返回 401 或SIGN_ERROR,提示签名错误。

原因:签名串的五行格式不对,常见的有:URL 路径带了查询参数、请求体被 json_encode 后又做了转义、时间戳用了毫秒、每行末尾漏了\n。

解决:打印出签名串逐行核对。URL 路径只取 path 部分,不带域名和查询串;请求体用json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),避免斜杠被转义;时间戳用秒级;每行末尾必须有\n,包括最后一行。

5.2 回调收不到:notify_url 不可达

现象:用户支付成功,但订单状态一直不变,日志里没有回调记录。

原因:notify_url 是内网地址、端口没开放、HTTPS 证书无效,或者服务器防火墙拦截了微信的回调 IP。

解决:先用 curl 从外网访问 notify_url 确认可达;检查 Nginx 或 Apache 是否放行了 POST 请求;确认 SSL 证书有效且完整。本地开发用内网穿透工具映射一个临时公网地址,但注意这个地址每次重启会变,测试完记得更新配置。

5.3 回调验签失败:平台证书过期

现象:回调能收到,但验签一直失败,日志报回调验签失败。

原因:微信平台证书有有效期,过期后需要重新下载。或者下载证书时用错了接口,拿到的不是平台证书。

解决:调用/v3/certificates接口下载最新平台证书,接口返回的证书是加密的,需要用 APIv3 密钥解密后再保存。建议写个定时任务每天检查证书有效期,提前 7 天告警。

5.4 订单状态不一致:缺少幂等处理

现象:同一笔订单被处理多次,用户被重复发货或重复加积分。

原因:微信回调可能重试多次,如果每次回调都无条件更新订单状态,就会重复处理。

解决:更新订单前先SELECT ... FOR UPDATE锁行,判断当前状态是否为「待支付」,只有待支付才更新为「已支付」。或者用UPDATE orders SET status='paid' WHERE out_trade_no=? AND status='pending',根据 affected rows 判断是否首次处理。

5.5 金额对不上:单位搞混

现象:下单金额和实际支付金额差 100 倍。

原因:微信支付金额单位是分,很多人在前端传的是元,后端没转换直接传给微信。

解决:统一在业务层用分做单位,前端展示时再除以 100。数据库存金额字段用 int 类型存分,避免浮点精度问题。退款金额同理,单位也是分。

6. 验收与答辩:怎么证明这套支付方案真的跑通了

验收一套支付方案,不能只看「能付款成功」,要覆盖正常流程和异常流程。我一般会准备一张验收清单,逐项打勾。

验收项操作步骤预期结果
JSAPI 下单公众号内点击支付唤起微信支付,付款成功
Native 下单PC 端展示二维码扫码后付款成功
回调验签查看回调日志验签通过,订单状态更新
重复回调手动重放回调请求订单只更新一次
退款调用退款接口退款成功,状态同步
签名错误故意改错私钥接口返回签名错误
证书过期用过期证书验签验签失败,日志有记录

答辩时,评委最常问的几个问题:签名串怎么构造的、回调怎么保证幂等、平台证书怎么管理、金额单位是什么。这几个问题在前面章节里都有答案,提前准备好对应的代码片段和日志截图。

还有一个技巧:把整个支付流程的日志按订单号串起来,从下单到回调到状态更新,一条链路打印出来。答辩时直接展示这个日志,比讲十页 PPT 都有说服力。我习惯在关键节点都打日志,包括请求参数、签名串、应答内容、回调原文、解密结果,出问题时能快速定位是哪一环。

从那以后我每次接支付,都强制走一遍「下单 → 支付 → 回调 → 对账」的完整链路,并且用沙箱环境模拟一次回调重试,确认幂等逻辑生效才敢上线。这套 PHP 微信支付 V3 案例的完整代码和配置模板,按上面的目录结构整理好了,需要参考的同学可以拿去对照自己的项目改。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进