PHP对接微信支付与支付宝支付:签名、回调验签与订单处理全解析
2026/9/15 2:18:46 网站建设 项目流程

简介:基于CI框架开发的PHP支付对接方案,面向需要快速接入微信与支付宝付款的Web开发者。资源覆盖两类支付平台:微信侧支持内置浏览器JSAPI直付与PC/H5扫码(Native)支付,支付宝侧支持手机站调起APP支付与电脑网站跳转官网付款码支付,四条链路贴合国内主流电商与内容付费场景。压缩包共294个文件,以242个PHP业务代码和45个HTML页面为主,其中PHP文件包含控制器、模型与支付回调逻辑,HTML用于示例页面;另附.htaccess伪静态规则、SQL初始化脚本及字体资源,整体仅775KB,轻量易部署。目前已有2070人浏览学习,适合具备一定PHP基础、希望参考官方SDK二次封装思路或直接复用支付逻辑的开发者。文件按CI框架标准结构组织,支付参数集中于配置项,订单表SQL可快速落地;结合演示文章与代码注释,便于对照跑通流程并针对自身业务调整回调处理。

1. PHP 对接微信支付、支付宝支付,卡点从来不在 SDK

做电商、SaaS 和会员系统的后端,迟早会接到同一个需求:一套订单体系,微信支付和支付宝都要通。很多人第一反应是装官方 SDK 照着 README 跑通下单,结果上线第一周就被异步通知折腾到怀疑人生——微信回调验签不过、支付宝报文解析出来金额对不上、同一笔订单收到三次重复通知导致发了两遍货。这两家支付的核心流程本质相同,都是“预下单 → 前端扫码或跳转 → 平台异步通知 → 后端验签改订单状态”,但签名算法、证书体系、回调报文格式、金额单位完全不在一个频道上。这篇从 PHP 后端视角,把两家从下单到回调验签的完整链路收口到一套流程里,重点写那些文档里含糊、联调时一定会踩的边界。

2. 微信支付 v3 的签名头与 Native 下单,手写一遍就懂

2.1 微信支付 v3 的权限模型,搞混这几个 key 就会一直验签失败

微信支付 v3 比老版 v2 直连了不少,但权限配置仍然有四个东西容易混:商户号 mchid、APIv3 密钥、商户 API 证书、微信支付公钥。先从表里理清楚各自干什么用。

配置项用途获取位置
商户号 mchid标识商户身份,下单和回调都会带商户平台首页
AppID公众号/小程序/App 的应用标识,下单时传给微信公众号或小程序后台
APIv3 密钥32 字节字符串,解密回调里的 resource 密文商户平台 → API 安全
商户 API 私钥给请求签名,对应 apiclient_key.pem申请 API 证书时生成
商户证书序列号签名头里的 serial_no 字段商户平台 → API 安全
微信支付公钥验签微信回调 HTTP 头的 signature商户平台 → API 安全

这里最常见的误用是拿 APIv3 密钥去验回调签名。APIv3 密钥只参与 AES-256-GCM 解密,回调头部的Wechatpay-Signature必须用微信支付公钥验。另一个高频坑是序列号填错:商户证书序列号和微信支付公钥的序列号是两串不同的值,填错时请求直接返回 401,连业务参数都走不到。

2.2 构造 Authorization 和 Native 下单请求,串号乱序必报错

微信支付 v3 的 HTTP 签名格式固定,与其封装 SDK,不如手写一次,出了问题能直接定位到是证书、序列号还是签名串的问题。以下代码用 PHP 的openssl扩展生成签名头。

<?php function wechatAuthHeader(string $method, string $url, string $body): string { $mchid = '16xxxxxx'; $serialNo = '你的商户证书序列号'; $privateKey = openssl_pkey_get_private( file_get_contents(__DIR__ . '/apiclient_key.pem') ); $timestamp = time(); $nonce = bin2hex(random_bytes(16)); // 官方要求的签名串:method、url、timestamp、nonce、body 按换行拼接 $message = "{$method}\n{$url}\n{$timestamp}\n{$nonce}\n{$body}\n"; openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); return sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"', $mchid, $nonce, $timestamp, $serialNo, base64_encode($signature) ); }

这段代码的签名串顺序固定,body 为空串时也要以\n结尾。serial_no对应的是商户 API 证书的序列号,不是公钥 ID。openssl_signOPENSSL_ALGO_SHA256,对应 HTTP 签名里的 SHA256-RSA2048,不需要额外装扩展。

下单请求把签名头带上,body 里的金额单位是分。

<?php $body = json_encode([ 'appid' => 'wx1234567890abcdef', 'mchid' => '16xxxxxx', 'description' => '商品订单-20240101-001', 'out_trade_no' => '20240101001', 'notify_url' => 'https://pay.example.com/wechat/notify', 'amount' => ['total' => 9900, 'currency' => 'CNY'], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $request = new GuzzleHttp\Psr7\Request( 'POST', 'https://api.mch.weixin.qq.com/v3/pay/transactions/native', [ 'Authorization' => wechatAuthHeader('POST', '/v3/pay/transactions/native', $body), 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], $body ); $resp = (new GuzzleHttp\Client())->send($request); $codeUrl = json_decode($resp->getBody()->getContents(), true)['code_url'];

JSON 序列化时一定加JSON_UNESCAPED_SLASHES,否则 notify_url 里的斜杠被转义成\/,会导致签名串和 body 不一致,微信返回SIGN_ERRORcode_urlweixin://开头的字符串,后端用它生成二维码,用户扫码后在微信内完成支付,随后微信把结果异步通知到 notify_url。

这里要特别强调一个联调经验:签名用的 body 和实际发出去的 body 必须逐字节一致。有人先对 body 签名,又通过框架的请求中间件把 JSON 重新格式化,最后就是验签失败。排在最后再检查下,请求日志里把原始 body 原样带回,跟签名时用的一对比就知道问题在哪。

2.3 回调节点两道工序:先验签再解密,顺序不能反

微信支付回调的 Content-Type 是 application/json,请求头里带Wechatpay-TimestampWechatpay-NonceWechatpay-Signature。第一道工序用微信支付公钥验签,第二道工序用 APIv3 密钥解密 resource 得到业务数据。

<?php function verifyWechatSign(array $headers, string $rawBody): bool { $timestamp = $headers['wechatpay-timestamp']; $nonce = $headers['wechatpay-nonce']; $signature = $headers['wechatpay-signature']; $publicKey = file_get_contents(__DIR__ . '/wechat_platform_pub.pem'); // 验签串:timestamp + 换行 + nonce + 换行 + 原始body + 换行 $message = "{$timestamp}\n{$nonce}\n{$rawBody}\n"; $ok = openssl_verify( $message, base64_decode($signature), openssl_pkey_get_public($publicKey), OPENSSL_ALGO_SHA256 ); return $ok === 1; }

openssl_verify返回 1 才是通过,0 是验签失败,-1 是参数错误。回调验签针对的是原始请求 body,不是 json_decode 后的数组。很多框架会自动解析 JSON,所以要拿原始 body 需要在中间件里提前用file_get_contents('php://input')存一份。

验签通过后,业务数据在resource.ciphertext里,使用 AEAD_AES_256_GCM 加密。这里有个和 PHP 官方文档对不上的细节:微信回调结构里没有单独的 tag 字段,AEAD 算法把 tag 附加在密文尾部,openssl_decrypt需要把密文拆开再解密。

<?php function decryptWechatResource(array $resource, string $apiv3Key): array { $ciphertext = base64_decode($resource['ciphertext']); // GCM 的 tag 是密文最后 16 字节 $tag = substr($ciphertext, -16); $content = substr($ciphertext, 0, -16); $plaintext = openssl_decrypt( $content, 'aes-256-gcm', $apiv3Key, OPENSSL_RAW_DATA, $resource['nonce'], $tag, $resource['associated_data'] ?? '' ); if ($plaintext === false) { throw new RuntimeException('微信回调解密失败,检查 APIv3 密钥'); } return json_decode($plaintext, true); }

解密失败大概率是 APIv3 密钥配置错误,或者密文被框架或日志组件改动过。解密得到的数组里包含out_trade_notransaction_idamount.totaltrade_state等字段。业务处理完成后,回调接口要返回 HTTP 200,body 为{"code":"SUCCESS","message":"成功"};如果业务处理失败或验签不通过,返回 4xx 或 5xx,微信会按退避策略重试同一回调。

3. 支付宝当面付与异步通知,RSA2 验签的细节都在参数拼接上

3.1 密钥关系比微信多一环:应用公钥必须上传

支付宝的 API 体系和微信最大区别是:你既要有自己的“应用私钥/应用公钥”对,又要把应用公钥上传到开放平台,换取平台下发的支付宝公钥。请求用自己的应用私钥签名,支付宝响应用支付宝公钥验,两边各管各的。

配置项用途说明
应用私钥对所有请求参数签名应用公钥对在本地生成
应用公钥上传到支付宝开放平台上传后生成应用公钥证书
支付宝公钥验签支付宝的异步通知和同步响应每个应用都不一样,要去开放平台复制
AES 密钥可选,用于加密报文字段一般对接不启用,启用后签名和验签不受影响

签名算法配置为 RSA2,对应 SHA256withRSA,这是 2024 年后支付宝主推的算法,老项目的 RSA 需要尽快升级。网关固定为https://openapi.alipay.com/gateway.do,提交方式支持 GET 和 POST 表单,实际对接用 POST 更稳妥。

3.2 组装公共参数和 biz_content,手工签名比 SDK 更可控

支付宝请求由公共参数和业务参数biz_content组成。公共参数里必填 app_id、method、format、charset、sign_type、timestamp、version,以及本次调用的 notify_url。签名前先把所有参数按 key 做字典序排序,拼接成key=value&串,再用应用私钥加签。这里的拼接规则和微信完全不同,不能对 value 做 urlencode。

<?php function alipaySign(array $params, string $appPrivateKey): string { ksort($params); $pairs = []; foreach ($params as $key => $value) { if ($key === 'sign' || $value === '') { continue; } // 支付宝协议要求原样拼接,不能 urlencode $pairs[] = "{$key}={$value}"; } $signStr = implode('&', $pairs); $privateKey = openssl_pkey_get_private($appPrivateKey); openssl_sign($signStr, $signature, $privateKey, OPENSSL_ALGO_SHA256); return base64_encode($signature); }

组装当面付预下单请求时,biz_content本身是一个 JSON 字符串,它作为公共参数的一个 value 参与签名。

<?php $biz = [ 'out_trade_no' => '20240101001', 'total_amount' => '99.00', 'subject' => '商品订单', ]; $publicParams = [ 'app_id' => '20210031xxxxxxxx', 'method' => 'alipay.trade.precreate', 'format' => 'JSON', 'charset' => 'utf-8', 'sign_type' => 'RSA2', 'timestamp' => date('Y-m-d H:i:s'), 'version' => '1.0', 'notify_url' => 'https://pay.example.com/alipay/notify', 'biz_content' => json_encode($biz, JSON_UNESCAPED_UNICODE), ]; $publicParams['sign'] = alipaySign($publicParams, $appPrivateKey); // 使用 Guzzle 或 curl 以表单方式 POST 到网关 $resp = (new GuzzleHttp\Client())->post('https://openapi.alipay.com/gateway.do', [ 'form_params' => $publicParams, ]); $result = json_decode($resp->getBody()->getContents(), true); // 支付二维码内容在 $result['alipay_trade_precreate_response']['qr_code'] 里

这里有两个细节容易翻车。第一,biz_content里的total_amount单位是元,且必须保留两位小数,和微信的分正好相反,这是跨支付渠道最容易出 bug 的地方。第二,json_encode必须用JSON_UNESCAPED_UNICODE,否则中文被转义成\uXXXX,签名字符串和支付宝服务端重新拼出来的不一致,返回sign check fail

3.3 异步通知验签的正确姿势,先验签名再查订单

支付宝的异步通知不是 JSON,而是application/x-www-form-urlencoded表单,PHP 侧直接用$_POST接收。验签时把表单数组去掉signsign_type,按同签名时一样的规则排序拼接,用支付宝公钥验签。

<?php function verifyAlipayNotify(array $formData, string $alipayPublicKey): bool { $params = $formData; unset($params['sign'], $params['sign_type']); ksort($params); $pairs = []; foreach ($params as $key => $value) { $pairs[] = "{$key}={$value}"; } $signStr = implode('&', $pairs); $ok = openssl_verify( $signStr, base64_decode($formData['sign']), openssl_pkey_get_public($alipayPublicKey), OPENSSL_ALGO_SHA256 ); return $ok === 1; }

验签通过后还要做三层校验:一是检查app_id是否为本应用的 ID,防止伪造通知打到回调地址;二是核对out_trade_no对应的订单状态,已经处理过的直接返回 success;三是比对total_amount与订单金额,支付宝文档虽然要求回调金额只做参考,但这里宁可额外校验一次,避免中间人篡改用小金额发起支付后伪造大金额通知。trade_status只有TRADE_SUCCESS才进入发货流程,TRADE_FINISHED可以按业务需求处理。

确认业务成功后,支付宝要求回调接口返回纯文本success,注意不是 JSON,不要带双引号。返回其他内容会触发支付宝重发通知,重发间隔逐次拉长,但不会取消。

4. 把两家回调收口成同一套支付单调度

4.1 两家的回调数据差异太大,先统一映射为内部结构

微信回调解密后的数组和支付宝表单的参数名差异很大,直接散落在业务代码里,后面维护状态机、对账、退款都要痛苦。常见做法是加一个渠道适配层,把差异字段统一映射成内部协议。

业务含义微信字段支付宝字段差异点
商户订单号out_trade_noout_trade_no都是字符串,直接用
平台交易号transaction_idtrade_no微信在 resource 里,支付宝在顶层
实付金额amount.totaltotal_amount微信分,支付宝元
支付状态trade_state=SUCCESStrade_status=TRADE_SUCCESS微信只通知成功,支付宝有多种状态
付款方标识payer.openidbuyer_id微信限于公众号/小程序场景

统一映射函数在实战中一般长这样,两个渠道各自填充,业务层只感知内部结构。

<?php function normalizePaymentNotify(string $channel, array $raw): array { if ($channel === 'wechat') { return [ 'out_trade_no' => $raw['out_trade_no'], 'channel_trade_no'=> $raw['transaction_id'], 'paid_amount' => (int) $raw['amount']['total'], // 单位:分 'paid_at' => $raw['success_time'] ?? date('Y-m-d H:i:s'), 'buyer_id' => $raw['payer']['openid'] ?? '', ]; } if ($channel === 'alipay') { // 支付宝金额单位是元,转成分统一存储 return [ 'out_trade_no' => $raw['out_trade_no'], 'channel_trade_no'=> $raw['trade_no'], 'paid_amount' => (int) round($raw['total_amount'] * 100), 'paid_at' => $raw['gmt_payment'], 'buyer_id' => $raw['buyer_id'] ?? '', ]; } throw new InvalidArgumentException('不支持的支付渠道'); }

转换后内部统一用分存储和比较,避免浮点误差。这个适配层同时为后面接退款、对账打下了基础,新渠道接入时只需要加一个映射分支。

4.2 幂等和状态机,重复通知不能改变订单状态

支付平台的异步通知是“至少一次”语义,同一个支付成功事件可能会重发数次,加上接口超时后人工补单,同一个out_trade_no可能同时有多个请求在跑。只靠数据库唯一索引不够,先加 Redis 锁再做业务更新。

<?php $lockKey = "pay:notify:{$channel}:{$outTradeNo}"; $locked = $redis->set($lockKey, 1, ['NX', 'EX' => 10]); if (!$locked) { // 另一个请求正在处理,直接返回成功,不要再触发重试 http_response_code(200); exit($channel === 'wechat' ? '{"code":"SUCCESS"}' : 'success'); } try { $pdo->beginTransaction(); $stmt = $pdo->prepare('SELECT status, total_fee FROM orders WHERE order_no = ? FOR UPDATE'); $stmt->execute([$outTradeNo]); $order = $stmt->fetch(); if ($order['status'] === 1) { // 订单已支付成功,幂等返回 $pdo->commit(); return; } if ((int) $order['total_fee'] !== $paidAmount) { // 金额不符,进入人工核查队列 $pdo->rollBack(); return; } $pdo->prepare('UPDATE orders SET status = 1, paid_at = NOW() WHERE order_no = ?') ->execute([$outTradeNo]); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); // 业务异常要抛出,让支付平台重试 throw $e; }

这段逻辑的关键是FOR UPDATE行锁。Redis 锁只解决“请求并发进来”的情况,FOR UPDATE解决锁过期后第二个请求进入时的状态判断。金额比较在事务内用整数比较,绝不在这一步允许浮点运算。业务异常向上抛出后,微信和支付宝都会按自己的重试策略再次推送通知。

4.3 回调失败的补偿,Redis Stream 比裸定时任务更稳

回调处理失败有各种原因:数据库挂了、库存服务网络抖动、代码发布期间请求丢失。支付平台的重试只能覆盖一部分场景,比如微信只重试 24 小时内的通知,超过时限就得靠主动补偿。把待核验的订单投递到 Redis Stream,用消费组做补偿。

<?php // 补偿队列:投递超时未支付成功的订单 $redis->xadd('pay:compensate', '*', [ 'order_no' => $orderNo, 'channel' => $channel, 'retry_at' => time() + 120, ]); // 消费端:从消费组读取消息 while ($messages = $redis->xreadgroup( 'pay-compensate-group', 'compensate-worker-1', 'pay:compensate', '>', 1, 3000 )) { foreach ($messages as $stream => $items) { foreach ($items as $msgId => $item) { // 调微信查单接口或支付宝 alipay.trade.query 确认支付状态 // 已支付则走补单流程,未支付则投递回队列等下一轮 $redis->xack('pay:compensate', 'pay-compensate-group', [$msgId]); } } }

消费组保证每条消息至少被一个 worker 处理,消息确认前不会丢失。查单接口在微信是 HTTP GET/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid=xxx,支付宝是alipay.trade.query,两者都需要重新签名。用这个方式做补偿,配合 PHP 常驻进程或队列 Worker,能把 99% 的漏单问题兜住。

5. 退款、对账单和投诉回调,这几件收尾的事别等上线后补

支付对接核心链路跑通后,还有三个接口属于“不用推广但必须接”的收尾工程:退款、对账单、投诉回调。

微信退款走POST /v3/refund/domestic/refunds,请求签名和下单完全一样,body 里的out_trade_noout_refund_noamount.refundamount.total四个字段缺一不可。注意退款金额单位同样是分,且退款接口没有单独的回调签名头,验签方式与支付回调一致。支付宝退款调用alipay.trade.refund,退款金额单位是元,同步返回结果,不需要异步通知,但要在返回后立刻查一次退款状态确认成功。

对账单在两个平台的字段格式各不相同,微信下载后需要用 APIv3 密钥解密,支付宝则是一个账单下载链接。建议用每日定时任务拉取前一天的账单,按out_trade_no做 KEY 与本地订单表逐笔核对,金额不一致的订单进入人工审核队列。这个动作能帮你发现“用户已支付但回调没收到”的漏单,也能回查个别订单在支付平台侧被改价的异常情况。

投诉回调是另一套独立配置。微信支付消费者投诉 2.0 的回调地址、签名公钥、加解密密钥都与支付回调不同,收到投诉后先验签解密,再根据complaint_state判断是否已经处理过。这里要特别注意:投诉回调返回格式和支付回调相同,都需要 HTTP 200 加{"code":"SUCCESS"},否则微信会持续重推,导致投诉处理超时被判服务异常。支付宝的投诉体系走客服系统,不开放实时回调,但可以在开放平台配置风险预警接口,主动感知异常交易。

退款与投诉处理完,建议在 cron 里加一条“订单快照持久化”的定时任务,每天把两个渠道的交易快照落一份到本地独立表,保留 180 天。未来业务侧排查纠纷、做财务对账,乃至配合审计调取数据,都可以直接查这张表,不用再回头翻支付平台的历史接口。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询