十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP对接微信支付与支付宝支付:签名、回调验签与订单处理全解析

PHP对接微信支付与支付宝支付:签名、回调验签与订单处理全解析 简介基于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_sign用OPENSSL_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_ERROR。code_url是weixin://开头的字符串后端用它生成二维码用户扫码后在微信内完成支付随后微信把结果异步通知到 notify_url。这里要特别强调一个联调经验签名用的 body 和实际发出去的 body 必须逐字节一致。有人先对 body 签名又通过框架的请求中间件把 JSON 重新格式化最后就是验签失败。排在最后再检查下请求日志里把原始 body 原样带回跟签名时用的一对比就知道问题在哪。2.3 回调节点两道工序先验签再解密顺序不能反微信支付回调的 Content-Type 是 application/json请求头里带Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-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_no、transaction_id、amount.total、trade_state等字段。业务处理完成后回调接口要返回 HTTP 200body 为{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 做字典序排序拼接成keyvalue串再用应用私钥加签。这里的拼接规则和微信完全不同不能对 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接收。验签时把表单数组去掉sign和sign_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_stateSUCCESStrade_statusTRADE_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}?mchidxxx支付宝是alipay.trade.query两者都需要重新签名。用这个方式做补偿配合 PHP 常驻进程或队列 Worker能把 99% 的漏单问题兜住。5. 退款、对账单和投诉回调这几件收尾的事别等上线后补支付对接核心链路跑通后还有三个接口属于“不用推广但必须接”的收尾工程退款、对账单、投诉回调。微信退款走POST /v3/refund/domestic/refunds请求签名和下单完全一样body 里的out_trade_no、out_refund_no、amount.refund、amount.total四个字段缺一不可。注意退款金额单位同样是分且退款接口没有单独的回调签名头验签方式与支付回调一致。支付宝退款调用alipay.trade.refund退款金额单位是元同步返回结果不需要异步通知但要在返回后立刻查一次退款状态确认成功。对账单在两个平台的字段格式各不相同微信下载后需要用 APIv3 密钥解密支付宝则是一个账单下载链接。建议用每日定时任务拉取前一天的账单按out_trade_no做 KEY 与本地订单表逐笔核对金额不一致的订单进入人工审核队列。这个动作能帮你发现“用户已支付但回调没收到”的漏单也能回查个别订单在支付平台侧被改价的异常情况。投诉回调是另一套独立配置。微信支付消费者投诉 2.0 的回调地址、签名公钥、加解密密钥都与支付回调不同收到投诉后先验签解密再根据complaint_state判断是否已经处理过。这里要特别注意投诉回调返回格式和支付回调相同都需要 HTTP 200 加{code:SUCCESS}否则微信会持续重推导致投诉处理超时被判服务异常。支付宝的投诉体系走客服系统不开放实时回调但可以在开放平台配置风险预警接口主动感知异常交易。退款与投诉处理完建议在 cron 里加一条“订单快照持久化”的定时任务每天把两个渠道的交易快照落一份到本地独立表保留 180 天。未来业务侧排查纠纷、做财务对账乃至配合审计调取数据都可以直接查这张表不用再回头翻支付平台的历史接口。本文还有配套的精品资源点击获取
返回列表