简介:面向已具备PHP面向对象基础、需要在项目中接入微信支付的开发者,这套案例围绕微信支付V3接口展开并兼顾V2部分能力,把订单创建、Native扫码与JSAPI支付、付款码支付、回调通知、订单查询、退款申请与退款查询、企业付款及红包等常用场景做了统一封装,省去逐条对照官方文档拼装请求的麻烦。全套共15个文件,以14个PHP源码为主体,另附1个Markdown说明文档,压缩包约42KB,体量轻便却分出配置入口、接口封装与调用示例几个层次,既能直接嵌入现有工程,也适合拆开单独研读。目前已有2013人学习。读者可从中了解V3下RSA2048签名与商户证书配置、回调报文验签、异步通知的幂等处理与日志排查思路,并借助说明文档快速跑通一条完整支付链路,作为接入初期的脚手架与排错参考较为实用。
1. 从一次 401 签名失败说起
一个跑了两年的 PHP 商城,原本对接的是 V2 版支付,商户平台提示接口要迁移到 V3。本以为换个地址、改个密钥就能收工,结果第一次调 JSAPI 下单就返回 401,日志里只有一句签名错误。翻开文档才发现,PHP 微信支付 V3 这一代把签名体系整个换掉了:不再拼 key 做 MD5,改成 SHA256-RSA 非对称签名;请求要带 Authorization 头;应答要验签;回调报文还得用 AES-256-GCM 解密。这套方案要解决的就是在纯 PHP 环境里把 V3 的签名、验签、解密、幂等回调完整跑通,适合正在做支付模块课设、毕设,或者要给现有系统补一套可交付支付能力的读者。下面按工程结构、签名链路、支付主流程、避坑、验收一路铺开。
2. 工程结构与依赖选型:SDK、扩展与配置的三层划分
很多人卡在第一步并不是因为签名难,而是因为文件没放对、密钥读不出来、序列号抄错了。V3 对身份材料的要求比 V2 严格得多,它认的不是“密钥字符串”,而是一整套证书体系。所以动手写代码之前,先把工程骨架和材料落位定下来,后面调试能省掉一大半时间。
2.1 官方 SDK 与手写签名的取舍
PHP 这边的选择其实只有两条路。第一条是直接用官方提供的 PHP SDK,它内部封装了请求签名、应答验签、平台证书下载与自动更新,调用方式接近 Guzzle 的 HTTP 客户端,业务代码只需要关心请求体和返回值。第二条是自己用openssl_*系列函数手写一个签名器,请求走 cURL 或 Guzzle,验签解密全部自己控制。
我的建议是:正式项目优先上官方 SDK,教学项目、毕设、需要讲清楚原理的场景手写一遍。原因很实际——平台证书会定期轮换,SDK 帮你处理了缓存和刷新,手写版如果只硬编码一张证书,某天早上会突然全线报“验签失败”,而你在业务日志里几乎看不出原因。
手写版的价值在于可控:签名串到底怎么拼、body 有没有被二次编码、header 里的 serial 用的是哪一张证书,全都在你眼皮底下。毕设答辩时被问到“V3 的签名流程是什么”,能白板画出来的和只会$client->post()的,观感差距很大。
不管走哪条路,openssl扩展是硬依赖,php -m | grep openssl必须先确认。另外json和curl基本是标配,不用特别处理。
2.2 目录分层与密钥配置的落位
V3 需要准备的材料一共四样:商户 API 证书(apiclient_cert.pem)、商户私钥(apiclient_key.pem)、商户证书序列号、APIv3 密钥(32 位)。平台证书可以在运行时下载,也可以预先落盘。
目录上我一般这样分,让证书和代码彻底隔离:
project/ ├── cert/ # 证书目录,禁止 web 直接访问 │ ├── apiclient_cert.pem # 商户 API 证书 │ ├── apiclient_key.pem # 商户私钥 │ └── platform_cert.pem # 平台证书(可定期更新) ├── config/ │ └── payment.php # 支付配置,从环境变量读敏感项 ├── src/ │ └── Pay/ │ ├── Signer.php # 签名 / 验签 / 解密 │ ├── Client.php # HTTP 请求封装 │ └── Notify.php # 回调处理 └── public/ └── notify.php # 回调入口配置项集中在一个文件里,敏感值走环境变量,避免私钥和 APIv3 密钥进 Git:
<?php // config/payment.php return [ 'appid' => getenv('WXPAY_APPID'), 'mchid' => getenv('WXPAY_MCHID'), 'serial_no' => getenv('WXPAY_CERT_SERIAL'), // 商户证书序列号 'private_key_path' => __DIR__ . '/../cert/apiclient_key.pem', 'apiv3_key' => getenv('WXPAY_APIV3_KEY'), // 必须是 32 位 'platform_cert_path'=> __DIR__ . '/../cert/platform_cert.pem', 'notify_url' => 'https://your-domain.com/notify.php', ];序列号不用手抄,直接从证书里取,抄错一位就是 401:
openssl x509 -in cert/apiclient_cert.pem -noout -serial # 输出形如 serial=5A1B2C3D...,去掉前缀就是配置里的值cert/目录务必放在 web 根目录之外,或者用 Nginx 的location ~ /cert/ { deny all; }拦掉。私钥泄露的后果不用多解释,见过不止一个项目把.pem放在public下,被扫描器直接拉走。
提示:APIv3 密钥在商户平台自行设置,一旦设定平台不再明文展示。长度不是 32 位时,回调解密必然失败,且报错信息非常模糊,建议配置加载时就做一次长度校验,把问题拦在启动阶段。
3. 请求签名与应答验签:五段式签名串怎么拼
V3 的签名规则本身不复杂,难的是“一字不差”。签名串里多一个空格、少一个换行、body 被json_encode两次,结果都是 401,而错误响应里不会告诉你哪一行错了。这一章把签名串的构造、Authorization 头的拼装、应答验签和回调解密拆开写清楚。
3.1 签名串的构造与 Authorization 头
V3 的待签名串固定五行,每行以\n结尾,最后一行也必须带\n:
HTTP请求方法\n URL(含 query,不含协议和域名)\n 时间戳\n 随机串\n 请求报文主体\nGET 请求的报文主体是空串,但那一行和换行符不能省。URL 如果带参数,query 部分要原样拼进去,顺序不能重排。
下面是一个可以直接用的签名器:
<?php declare(strict_types=1); final class WxPaySigner { public function __construct( private string $mchId, private string $serialNo, private string $privateKeyPem, private string $apiv3Key ) {} /** * 生成 V3 请求的 Authorization 头 * @param string $method GET / POST * @param string $urlPath 不含域名的路径,如 /v3/pay/transactions/jsapi * @param string $body 原始请求体,必须与实际发送的字节完全一致,GET 传空串 */ public function authorization(string $method, string $urlPath, string $body = ''): string { $timestamp = (string) time(); $nonce = bin2hex(random_bytes(16)); // 32 位随机串 // 五段式签名串,每行都以 \n 结尾,最后一行同样需要 $message = $method . "\n" . $urlPath . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; openssl_sign($message, $rawSign, $this->privateKeyPem, OPENSSL_ALGO_SHA256); return sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"', $this->mchId, $nonce, base64_encode($rawSign), $timestamp, $this->serialNo ); } }几个参数的口径要说清楚。serial_no是商户证书的序列号,不是平台证书的;nonce是 32 位随机字符串,用bin2hex(random_bytes(16))正好 32 位;timestamp是秒级时间戳,和平台时间偏差过大会被拒。最关键的一条:$body必须是最终发出去的字节,不能这里传一个数组、那里再json_encode一遍,两次编码的结果只要有一个空格差异,签名就对不上。
调用侧要保持 body 只编码一次:
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $headers = [ 'Authorization: ' . $signer->authorization('POST', '/v3/pay/transactions/jsapi', $json), 'Accept: application/json', 'Content-Type: application/json', 'User-Agent: php-pay-demo/1.0', ];JSON_UNESCAPED_UNICODE不是必须的,但一旦用了,签名和发送必须用同一个$json变量。中途重新编码是新手最容易踩的坑。
3.2 应答验签与 AES-256-GCM 回调解密
请求发出去只是上半场。V3 的应答同样带签名,验签串是另一套三段结构:
应答时间戳\n 应答随机串\n 应答报文主体\n验签用的公钥来自平台证书,对应响应头里的Wechatpay-Serial。下面是验签与解密两个方法:
/** * 验证应答或回调的签名 * @param string $timestamp Wechatpay-Timestamp 响应头 * @param string $nonce Wechatpay-Nonce 响应头 * @param string $body 原始响应体 * @param string $signatureBase64 Wechatpay-Signature 响应头 * @param string $platformCertPem 平台证书内容 */ public function verify(string $timestamp, string $nonce, string $body, string $signatureBase64, string $platformCertPem): bool { $message = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; $pubKey = openssl_pkey_get_public($platformCertPem); if ($pubKey === false) { throw new RuntimeException('平台证书无法解析,检查 PEM 是否完整'); } $result = openssl_verify($message, base64_decode($signatureBase64), $pubKey, OPENSSL_ALGO_SHA256); return $result === 1; // 1 通过,0 不通过,-1 出错 } /** * 解密回调报文中的 resource 段(AES-256-GCM) * @param string $associatedData 密文对应的 associated_data * @param string $nonce 12 字节随机串 * @param string $ciphertextB64 base64 密文,末 16 字节是认证 tag */ public function decrypt(string $associatedData, string $nonce, string $ciphertextB64): string { $ciphertext = base64_decode($ciphertextB64); $tag = substr($ciphertext, -16); // GCM 的 tag 固定 16 字节 $data = substr($ciphertext, 0, -16); $plain = openssl_decrypt( $data, 'aes-256-gcm', $this->apiv3Key, OPENSSL_RAW_DATA, $nonce, $tag, $associatedData ); if ($plain === false) { throw new RuntimeException('回调解密失败:确认 APIv3 密钥为 32 位且 associated_data 原样传入'); } return $plain; }openssl_decrypt的第七个参数$tag和第八个参数$associatedData位置很容易记反,写反了就是返回false。associated_data在回调里通常是transaction这样的固定串,必须原样传,传null或空串都会解不开。
注意:验签失败不要直接跳过。应答验签失败意味着报文可能被篡改,或者是平台证书版本不对;回调验签失败则大概率是拿商户证书当平台证书用了。这两种情况的错误必须落日志,而不是只写一句“支付失败”。
4. 支付主链路实现:下单、回调与订单状态机
签名层跑通之后,业务层其实是标准流程:下单拿prepay_id、前端拉起支付、平台异步回调、服务端改单、用户看到结果。真正让项目翻车的是回调这一段——重复通知、乱序通知、伪造通知,都在这里发生。
4.1 三种下单方式与 JSAPI 的二次签名
常见的三条下单接口:
| 接口 | 路径 | 适用场景 |
|---|---|---|
| JSAPI | /v3/pay/transactions/jsapi | 公众号内、小程序内支付,需要 openid |
| Native | /v3/pay/transactions/native | PC 网站扫码支付,返回二维码链接 |
| H5 | /v3/pay/transactions/h5 | 手机浏览器外部页面,需要 scene_info |
请求体字段在三条接口上大同小异,核心是appid、mchid、out_trade_no、description、notify_url、amount.total。金额单位是分,整数,不要传小数,也不要在业务层用浮点运算累加金额。
$payload = [ 'appid' => $config['appid'], 'mchid' => $config['mchid'], 'description' => '订单-' . $orderNo, 'out_trade_no' => $orderNo, // 商户侧唯一,建议带业务前缀 'notify_url' => $config['notify_url'], // 必须是 https 公网可达 'amount' => ['total' => $amountFen, 'currency' => 'CNY'], 'payer' => ['openid' => $openid], // JSAPI 必填,Native/H5 不需要 ];拿到prepay_id之后,JSAPI 还要再做一次前端签名,这次签名用的是商户私钥,待签串只有四行:
$timeStamp = (string) time(); $nonceStr = bin2hex(random_bytes(16)); $package = 'prepay_id=' . $prepayId; $message = $appId . "\n" . $timeStamp . "\n" . $nonceStr . "\n" . $package . "\n"; openssl_sign($message, $rawSign, $privateKeyPem, OPENSSL_ALGO_SHA256); $frontendParams = [ 'appId' => $appId, 'timeStamp' => $timeStamp, 'nonceStr' => $nonceStr, 'package' => $package, 'signType' => 'RSA', 'paySign' => base64_encode($rawSign), ];这四行和请求签名那五行是两套规则,signType必须是RSA而不是MD5,package前缀不能省。前端拿到这组参数直接调起支付面板,paySign不对的表现是“支付面板打不开”或者“签名错误”,排查时先看package是不是漏了prepay_id=。
4.2 回调通知的验签解密与幂等落库
回调是整个支付链路里唯一由外部主动打进来的入口,处理顺序建议固定为:取原始 body → 验签 → 解密 resource → 校验金额与商户号 → 幂等更新订单 → 返回 200。
顺序不能乱,尤其不能先解密再验签,否则等于给伪造请求开了口子。
// public/notify.php $body = file_get_contents('php://input'); // 必须取原始字节,不能用 $_POST // header 名称在不同 SAPI 下大小写不一,用 $_SERVER 兜底更稳 $timestamp = $_SERVER['HTTP_WECHATPAY_TIMESTAMP'] ?? ''; $nonce = $_SERVER['HTTP_WECHATPAY_NONCE'] ?? ''; $signature = $_SERVER['HTTP_WECHATPAY_SIGNATURE'] ?? ''; $serial = $_SERVER['HTTP_WECHATPAY_SERIAL'] ?? ''; // 1. 验签,不通过直接 401,不写业务库 if (!$signer->verify($timestamp, $nonce, $body, $signature, $platformCertPem)) { http_response_code(401); exit('invalid signature'); } // 2. 解密 resource $notify = json_decode($body, true); $plain = $signer->decrypt( $notify['resource']['associated_data'] ?? '', $notify['resource']['nonce'], $notify['resource']['ciphertext'] ); $data = json_decode($plain, true); // 3. 校验商户号与金额,防止串单 if ($data['mchid'] !== $config['mchid'] || $data['trade_state'] !== 'SUCCESS') { http_response_code(200); // 非成功态也回 200,避免无意义重推 exit('ignored'); } // 4. 幂等更新:唯一索引 + 条件更新,天然挡住重复通知 $affected = $pdo->prepare( "UPDATE pay_order SET status = 1, transaction_id = ?, paid_at = NOW(), raw_notify = ? WHERE out_trade_no = ? AND status = 0 AND amount_fen = ?" )->execute([$data['transaction_id'], $body, $data['out_trade_no'], $data['amount']['total']]); // 5. 无论如何返回 200,让平台停止重推 http_response_code(200); echo json_encode(['code' => 'SUCCESS']);这里的$affected判断很关键:只有status = 0的订单才会被改成已支付,第二次、第三次重复通知打进来时affected为 0,业务副作用(发货、加积分、发消息)就不会重复执行。金额条件也不能省,它同时挡住了“金额被改小”的伪造场景。
提示:回调地址必须是 https 且公网可达,本地开发用内网穿透工具临时映射时,记得把
notify_url配成当次可用的地址,不要写死在代码里,否则换环境就得改代码。
4.3 订单表结构与状态流转
支付单表和业务订单表建议分开,支付单只关心“钱有没有到”,业务订单关心“货有没有发”。支付单的核心字段和索引:
CREATE TABLE `pay_order` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `out_trade_no` varchar(32) NOT NULL COMMENT '商户订单号', `transaction_id` varchar(64) DEFAULT NULL COMMENT '平台支付订单号', `amount_fen` int unsigned NOT NULL COMMENT '金额,单位分', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已关闭 3已退款', `prepay_id` varchar(64) DEFAULT NULL, `paid_at` datetime DEFAULT NULL, `raw_notify` text COMMENT '回调原文,便于对账与排查', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_out_trade_no` (`out_trade_no`), UNIQUE KEY `uk_transaction_id` (`transaction_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_transaction_id用唯一索引而不是普通索引,重复插入会直接报错,比在代码里SELECT判重可靠得多——并发场景下先查后插是有窗口期的。
状态流转只允许单向:0 → 1(支付成功)、0 → 2(超时关单)、1 → 3(退款)。1 → 0这种回退在任何代码路径里都不该出现,写状态更新时把WHERE status = 0带上,等于给自己加了一道保险。
5. 避坑与排查:签名失败、回调重复、证书过期的现场处置
支付模块的报错信息普遍很吝啬,401 就是 401,不会告诉你哪里错了。下面这几条是实际项目里出现频率最高的,按“现象 → 原因 → 解决”列出来,遇到问题时可以当检查表用。
现象一:下单接口稳定返回 401,签名错误,但同样的代码换个订单号又能过。原因通常是签名串和实际发送的 body 不是同一份字节。常见写法是先json_encode一次给签名,再json_encode一次给 cURL,两次结果如果因为数组顺序或转义选项不同而有差异,签名必然对不上。另一种是签名串最后一行忘了\n。解决办法是先把 body 存进变量,签名和发送都用这一个变量,并在签名器里加一行断言,确认$message以\n结尾。
现象二:回调一直验签失败,日志里openssl_verify返回 0。八成是拿商户 API 证书当平台证书用了。V3 里两套证书职责完全分开:商户私钥签自己的请求,平台证书验平台的应答和回调。还有一种情况是平台证书已经轮换,本地还是旧文件。解决办法是在启动时对比Wechatpay-Serial与本地证书序列号,不一致就去拉最新平台证书;生产环境不要只硬编码一张证书。
现象三:回调解密返回 false,没有任何错误信息。三个可能:APIv3 密钥长度不是 32 位;nonce传成了回调请求头里的Wechatpay-Nonce(应该用resource.nonce);associated_data被当成可选参数传了空串。排查时把这三个值分别打出来核对长度,associated_data在交易类回调里通常是transaction。
现象四:同一笔订单发货发了两次,用户收到两份。原因是回调是“至少一次”语义,平台在没收到 200 之前会按策略重推。如果业务逻辑只做了“查到订单未支付就发货”,重推进来时状态还没提交,就会重复执行。解决办法是把状态更新和业务副作用放在同一个事务里,用带status = 0条件的 UPDATE 影响行数做闸门,同时在transaction_id上建唯一索引兜底。
现象五:金额显示差了 100 倍。V3 所有金额字段单位是分,且是整数。业务层用元做展示没问题,但传给接口前必须intval(round($yuan * 100)),不能用浮点直接乘完截断,19.99 * 100在某些精度下会得到1998。数据库存分,展示层再除,这条规矩定下来之后能省掉大量对账麻烦。
现象六:本地调试正常,上了服务器回调收不到。先确认notify_url是 https 且公网可解析,再看 Nginx 有没有把Content-Type改掉或者加了 gzip。回调处理里必须用php://input取原始 body,框架的request->post()在application/json下通常取不到内容。还有一个隐蔽的点:某些 SAPI 下getallheaders()拿不到Wechatpay-*头,改用$_SERVER['HTTP_WECHATPAY_SIGNATURE']更稳。
6. 验收与答辩:用一套自检清单把支付模块跑通
支付模块的验收不能只靠“我点了一下能付”。答辩或者交付评审时,评委和负责人问的往往不是“能不能付”,而是“重复回调怎么办”“证书过期怎么办”“金额对不上怎么办”。所以验收要按清单走,每一条都能当场演示或者翻出日志。
我一般会把验收拆成四组,按顺序过:
| 分组 | 验收项 | 通过标准 |
|---|---|---|
| 签名链路 | 请求签名、应答验签、回调解密 | 三者各自有单测,篡改 body 后验签必须失败 |
| 下单链路 | JSAPI / Native 至少一条跑通 | 能拿到 prepay_id,前端能拉起面板 |
| 回调链路 | 正常回调、重复回调、伪造回调 | 正常改单一次,重复不改单,伪造返回 401 |
| 异常链路 | 金额不符、订单不存在、证书不匹配 | 均不产生业务副作用,日志有明确记录 |
单测部分不需要真连平台,把签名器单独抽出来,用固定的时间戳、随机串和 body 做断言,比对生成的签名串是否与预期字符串完全一致——这一步能挡住 90% 的低级错误。回调那部分更适合“报文回放”:把一次真实回调的 header 和 body 存成 fixture 文件,写个脚本反复灌进去,验证第一次改单、第二次不动、第三次仍然不动。
答辩时如果被追问实现细节,有几个点值得主动讲:为什么验签要放在解密之前(防止对伪造报文做无谓的解密运算,也避免把不可信数据带进业务逻辑);为什么幂等要靠数据库条件更新而不是先查后改(并发窗口);为什么平台证书要动态刷新(轮换是常态)。这几个问题答得出来,基本能说明这套东西是真跑过的,不是拼凑的。
还有一个容易被忽略的验收项:日志里能不能还原一次完整交易。我的做法是把每次请求的out_trade_no、Authorization头里的serial_no、响应体、回调原文全部落一张表,保留 90 天。对账出问题时,这张表就是黑匣子,比翻应用日志快得多。
从那以后我每次接支付相关的东西,都会先把这份清单打印出来贴在显示器边上,跑完一项划掉一项,不管工期多紧都不跳步。支付这个东西,后悔药很贵,前期多花两小时,后期能省两天。希望帮到你。
本文还有配套的精品资源,点击获取