资讯详情

资讯详情

ThinkPHP5集成微信支付与支付宝支付:签名、回调与订单同步实战

简介面向ThinkPHP5开发者的支付集成资料包专注解决微信支付、支付宝支付及网银支付在电商场景中的接入难题适合需要快速完成商户号配置、统一下单、签名验证与回调处理的PHP工程师。压缩包共2321个文件约4.76MB以PHP源码为主辅以data、phpt、md、xml、yml、json等配置与文档文件涵盖ThinkPHP5项目所需的SDK组件、测试用例及说明文档目录结构清晰便于按模块调用与二次开发。内容详细梳理了微信支付从创建订单到处理支付通知的完整流程以及支付宝即时到账、扫码支付的集成要点并强调了敏感信息加密与签名校验等安全实践。其中还包含网银支付对接思路并附有pem密钥、phpunit测试脚本等辅助配置示例可帮助开发者理解支付SDK在TP5中的目录组织与加载方式降低联调门槛。已有925人学习适合具备TP5基础、希望获得可参考支付实现方案的开发者。1. 先想清楚再动手ThinkPHP5 接微信支付和支付宝支付绕不开的那三座山在 ThinkPHP5 项目里集成微信支付和支付宝支付第一反应是去官方下载 SDK 塞进项目然后发现文档里的 DEMO 是原生 PHP 写法和 TP5 的类加载、依赖注入、异常处理全都不搭。拆过几套支付源码后发现真正要解决的不是「怎么调通一次交易」而是签名、回调验签、订单状态同步这三件事在框架里怎么组织。如果你手里有完整源码包大概率会看到它把支付封装成了 Service 层而不是让控制器里堆一堆 curl 和 XML。这套集成方案适合已经在用 TP5 做电商、知识付费或企服系统的开发者尤其是那种订单金额小、频率高、对掉单和回调延迟敏感的业务场景。2. 统一支付基类先搭好签名、请求、回调的地基2.1 为什么支付代码必须独立成层而不是写在控制器里很多 TP5 项目一开始是在商户控制器里直接写微信支付接口写完发现第一微信公众号支付、扫码支付、APP 支付要写三遍签名和请求逻辑第二支付宝和微信的证书、回调地址、日志策略完全没法复用第三控制器里塞了七八百行支付代码之后后面要加一个退款接口就要动控制器风险极高。这套集成资源里最值得抄的其实是它的分层思路支付网关层负责统一下单、查询、退款、渠道适配层区分微信和支付宝、回调处理层统一验签并更新订单。我在实际项目里一般会再加一个trade表来做本地订单和支付流水对账避免直接改业务订单表。2.2 支付基类的核心代码请求参数拼接与签名工具先看渠道适配层的骨架。这个类是所有支付渠道的入口微信支付宝各自继承它重写buildParams()和verifyNotify()即可。?php namespace app\common\pay; use think\facade\Log; abstract class PayBase { protected $config []; // 渠道配置 protected $gateway ; // 网关地址 public function __construct(array $config) { $this-config $config; $this-gateway $this-initGateway(); } // 初始化网关子类必须实现 abstract protected function initGateway(); // 构造支付参数子类必须实现 abstract public function buildParams(array $order, array $extra []); // 验签子类必须实现 abstract public function verifyNotify(array $params): bool; // 公共请求方法 protected function postXml(string $url, string $xml, array $headers []) { $ch curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $xml); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 30); curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge([ Content-Type: text/xml; charsetutf-8 ], $headers)); $response curl_exec($ch); $errno curl_errno($ch); curl_close($ch); if ($errno) { Log::error(支付请求失败curl错误码: {$errno}, url: {$url}); throw new \Exception(网络请求失败请稍后重试); } return $response; } }这段代码提醒你几个细节curl_setopt的CURLOPT_TIMEOUT必须设置支付接口慢的话 30 秒是底线更稳妥是 60 秒curl_errno不能只记日志要抛异常否则调用方拿到的就是空字符串后面的 JSON 解析会直接崩。还有一个容易忽略的点微信支付的接口 URL 在沙箱和正式环境不一样initGateway()用子类实现意味着你换环境时只需要改配置不用动调用方代码。2.3 签名工具类微信 MD5 和支付宝 RSA2 的差异封装签名是整个支付集成里最玄学的地方。微信用的是「参数名 ASCII 升序拼串 MD5」支付宝用的 RSA2 签名商家私钥签名、支付宝公钥验签格式完全不同。新手最容易翻车的是把两者混在同一个工具类里最后改一个渠道的签名把另一个渠道搞崩。?php namespace app\common\pay; class SignUtil { /** * 微信签名MD5 * param array $params 待签名参数 * param string $apiKey API密钥 */ public static function wechatSign(array $params, string $apiKey): string { ksort($params); $string ; foreach ($params as $key $value) { if ($value || $key sign) { continue; } $string . $key . . $value . ; } $string . key . $apiKey; return strtoupper(md5($string)); } /** * 支付宝签名RSA2 * param array $params 待签名参数 * param string $privateKey 应用私钥 */ public static function alipaySign(array $params, string $privateKey): string { unset($params[sign]); ksort($params); $string urldecode(http_build_query($params)); $privateKey -----BEGIN RSA PRIVATE KEY-----\n . wordwrap($privateKey, 64, \n, true) . \n-----END RSA PRIVATE KEY-----; $res openssl_pkey_get_private($privateKey); openssl_sign($string, $signature, $res, OPENSSL_ALGO_SHA256); return base64_encode($signature); } }逻辑说明微信签名前必须ksort做升序然后拼成a1b2的形式最后追加密钥串做 MD5。支付宝的http_build_query默认不做 URL 编码但签名串要求编码后的值所以urlencode后的结果要再urldecode一次这是很多接入支付宝 RSA2 的经典坑。而且支付宝私钥如果是从某开放平台下载的一般不带BEGIN RSA PRIVATE KEY头wordwrap 一下再拼上 BEGIN/END 标记否则openssl_pkey_get_private会一直返回 false。3. 微信支付落地从下单参数到异步回调完整流程3.1 扫码支付下单Native 支付十五个必传参数微信支付体系里扫码支付用 Native 模式最省事。用户扫二维码后微信服务器给你一个code_url前端拿它生成二维码图片。下单接口POST https://api.mch.weixin.qq.com/pay/unifiedorder需要提交的参数有 appid、mch_id、nonce_str、sign、body、out_trade_no、total_fee、spbill_create_ip、notify_url、trade_typeNATIVE、product_id 等。其中total_fee单位是分下订单时你从业务库里拿到的金额是元必须intval($amount * 100)转分。?php namespace app\common\pay; use think\facade\Config; class WechatPay extends PayBase { public function nativePay(array $order): array { $params [ appid $this-config[app_id], mch_id $this-config[mch_id], nonce_str md5(uniqid(mt_rand(), true)), body $order[title], out_trade_no $order[trade_no], total_fee intval(round($order[amount] * 100)), spbill_create_ip request()-ip(), notify_url $this-config[notify_url], trade_type NATIVE, product_id $order[product_id], ]; $params[sign] SignUtil::wechatSign($params, $this-config[api_key]); $xml $this-arrayToXml($params); $response $this-postXml(https://api.mch.weixin.qq.com/pay/unifiedorder, $xml); $result $this-xmlToArray($response); if ($result[return_code] SUCCESS $result[result_code] SUCCESS) { return [ code_url $result[code_url], trade_no $order[trade_no] ]; } throw new \Exception(微信下单失败: . ($result[return_msg] ?? 未知错误)); } }参数说明nonce_str用md5(uniqid(mt_rand(), true))生成保证并发下不重复spbill_create_ip必须填真实客户端 IP填了 127.0.0.1 在正式环境会被微信拒绝body不要超过 128 个字符超长会报PARAM_ERROR。下单成功后返回的code_url有效期是 2 小时过期后需要重新下单这个时间窗口你的前端二维码生成逻辑要处理。3.2 异步回调验签与订单更新无法想象的坑都在这异步回调是支付系统里掉单的根源。微信把支付结果 POST 到你配置的notify_url你收到后要做两件事第一验签确认是微信发的第二查业务订单确认金额和订单号匹配。验签通过后要先响应微信「成功」再更新本地订单否则微信会认为你没收到每 15 秒重试 3 次后就不再通知了。public function notify() { $xml file_get_contents(php://input); $params $this-xmlToArray($xml); // 验签 $sign $params[sign] ?? ; unset($params[sign]); $calcSign SignUtil::wechatSign($params, $this-config[api_key]); if ($calcSign ! $sign) { Log::error(微信回调验签失败: . json_encode($params)); return $this-wechatReturn(FAIL, 签名错误); } if ($params[return_code] SUCCESS $params[result_code] SUCCESS) { $orderNo $params[out_trade_no]; $transactionId $params[transaction_id]; $amount $params[total_fee] / 100; // 转换为元 // 更新订单查本地订单比对金额更新状态 $order OrderModel::where(trade_no, $orderNo)-find(); if (!$order || $order[status] ! 0) { // 幂等处理订单已更新过就直接返回成功不重复处理 return $this-wechatReturn(SUCCESS, OK); } if (abs($order[amount] - $amount) 0.01) { Log::error(回调金额与订单不符: 订单{$orderNo}本地{$order[amount]}微信{$amount}); return $this-wechatReturn(FAIL, 金额不符); } // 开始事务更新 Db::startTrans(); try { OrderModel::where(trade_no, $orderNo)-update([ status 1, pay_time time(), transaction_id $transactionId ]); Db::commit(); } catch (\Exception $e) { Db::rollback(); Log::error(订单更新失败: . $e-getMessage()); return $this-wechatReturn(FAIL, 更新失败); } return $this-wechatReturn(SUCCESS, OK); } return $this-wechatReturn(SUCCESS, OK); }这段代码把回调试错的三个关键点都处理了幂等性靠「订单状态不是 0 直接返回成功」防止重复回调把订单更新两次金额比对用abs($order[amount] - $amount) 0.01浮点数不能直接用比较事务里更新订单失败回滚后返回FAIL让微信继续重试。你可能注意到我在这里没有验证appid和mch_id严谨的做法是跑验签前先检查这两个字段和配置一致防止测试环境与正式环境配置混用时回调串场。3.3 退款接口证书加载与 Tp5 环境下的坑退款比下单麻烦微信退款接口https://api.mch.weixin.qq.com/secapi/pay/refund要求双向证书认证。TP5 项目里经常遇到「本地能退部署到服务器报证书错误」原因是证书路径写成相对路径而框架的入口文件在 public 目录下相对路径会变成public/apiclient_cert.pem当然找不到。我的习惯是存到config/目录外的独立目录比如runtime/certs/然后通过配置项读取绝对路径。public function refund(string $tradeNo, float $amount): array { $params [ appid $this-config[app_id], mch_id $this-config[mch_id], nonce_str md5(uniqid(mt_rand(), true)), out_trade_no $tradeNo, out_refund_no $tradeNo . _R . time(), total_fee intval(round($amount * 100)), refund_fee intval(round($amount * 100)), ]; $params[sign] SignUtil::wechatSign($params, $this-config[api_key]); $ch curl_init($this-gateway . /secapi/pay/refund); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $this-arrayToXml($params)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSLCERT, $this-config[cert_path]); curl_setopt($ch, CURLOPT_SSLKEY, $this-config[key_path]); $response curl_exec($ch); curl_close($ch); $result $this-xmlToArray($response); return $result; }注意这里的out_refund_no我拼了时间戳因为微信要求退款单号不能重复同一订单多次退款必须用不同的退款单号。证书路径必须用绝对路径TP5 里可以用Env::get(root_path) . runtime/certs/apiclient_cert.pem这种方式动态拼接避免部署环境迁移时路径失效。4. 支付宝支付对接电脑网站支付、异步通知与退款实现4.1 电脑网站支付从公共参数到页面跳转的完整构造支付宝电脑网站支付用alipay.trade.page.pay接口。和微信不同的地方在于支付宝走的是表单 POST 提交到支付页或者返回一段自动提交表单的 HTML前端拿到后document.form.submit()跳转。TP5 里处理这种场景我一般让接口返回跳转 HTML 而不是 JSON这样对接方便。?php namespace app\common\pay; class AlipayPay extends PayBase { public function pagePay(array $order): string { $params [ app_id $this-config[app_id], method alipay.trade.page.pay, charset utf-8, sign_type RSA2, timestamp date(Y-m-d H:i:s), version 1.0, notify_url $this-config[notify_url], return_url $this-config[return_url], biz_content json_encode([ out_trade_no $order[trade_no], product_code FAST_INSTANT_TRADE_PAY, total_amount number_format($order[amount], 2, ., ), subject $order[title], ], JSON_UNESCAPED_UNICODE), ]; $params[sign] SignUtil::alipaySign($params, $this-config[private_key]); // 构造自动提交表单 $html form idalipay_submit namealipay_submit actionhttps://openapi.alipay.com/gateway.do methodPOST; foreach ($params as $key $value) { $html . input typehidden name . $key . value . htmlspecialchars($value) . /; } $html . input typesubmit value立即支付 styledisplay:none/form; $html . scriptdocument.getElementById(alipay_submit).submit();/script; return $html; } }关键点biz_content在支付宝签名中的处理方式比较特殊它是整个 JSON 字符串作为参数值参与签名而不是拆成多个参数。total_amount是字符串格式且精确到两位小数不要传int或float否则订单金额可能变成12.0而不是12.00某些情况下会被支付宝拒收。number_format强制保留两位小数这是我在生产环境被支付宝返回「订单金额格式不合法」之后学到的。4.2 异步通知验签支付宝公钥与参数过滤逻辑支付宝的异步通知参数比微信复杂有trade_status、total_amount、out_trade_no、trade_no等。验签时要注意支付宝的签名规则里sign字段不参与签名且数组中的空值参数要过滤掉。这里最大的坑在于支付宝公钥和应用公钥是两个不同的东西你在网页上设置的是「应用公钥」验签需要用「支付宝公钥」——那张以-----BEGIN PUBLIC KEY-----开头、可以从开放平台「密钥」页面直接下载的密钥。public function verifyNotify(array $params): bool { $sign $params[sign] ?? ; unset($params[sign], $params[sign_type]); // 过滤空值和数组 $filtered array_filter($params, function ($value) { return $value ! $value ! null !is_array($value); }); ksort($filtered); $string urldecode(http_build_query($filtered)); $publicKey -----BEGIN PUBLIC KEY-----\n . wordwrap($this-config[public_key], 64, \n, true) . \n-----END PUBLIC KEY-----; $res openssl_pkey_get_public($publicKey); return (bool) openssl_verify($string, base64_decode($sign), $res, OPENSSL_ALGO_SHA256); }参数说明array_filter必须放在ksort之前否则空参数的位置会影响签名串拼接。urldecode(http_build_query())和签名工具里写的逻辑一样RSA2 验签时要保持与签名完全一致的字符串格式。支付宝回调里trade_status只有TRADE_SUCCESS和TRADE_FINISHED两个值是支付成功TRADE_CLOSED是未支付关闭判断时必须用in_array而不是 TRADE_SUCCESS一个值。4.3 支付宝退款新版接口的必填参数变化支付宝的退款接口是alipay.trade.refund比微信退款宽松很多不需要证书只需要签名正确且一次可以退部分金额refund_amount不能超过原订单金额。TP5 代码里调用方式和下单类似唯一需要注意的是新版接口要求out_trade_no和trade_no二选一不能同时传否则报invalid parameter。public function refund(string $tradeNo, float $amount): bool { $params [ app_id $this-config[app_id], method alipay.trade.refund, charset utf-8, sign_type RSA2, timestamp date(Y-m-d H:i:s), version 1.0, biz_content json_encode([ out_trade_no $tradeNo, refund_amount number_format($amount, 2, ., ), out_request_no $tradeNo . _ . time(), ], JSON_UNESCAPED_UNICODE), ]; $params[sign] SignUtil::alipaySign($params, $this-config[private_key]); $response $this-postForm($this-gateway, $params); $result json_decode($response, true); if (isset($result[alipay_trade_refund_response][code]) $result[alipay_trade_refund_response][code] 10000) { return true; } Log::error(支付宝退款失败: . json_encode($result)); return false; }这里我用out_request_no标识退款请求号作用是支持「同一订单多次退款不冲突」。如果业务逻辑是退款后要等支付宝异步通知结果再更新订单状态注意支付宝的退款通知是异步且不保证送达的生产环境里更可靠的方案是主动调用alipay.trade.refund.query查询退款结果而不是依赖通知。5. 支付联调避坑指南五个最容易让你抓狂的报错与处理方法5.1 微信支付报「签名错误请检查后重试」现象调用统一下单接口返回return_codeFAILreturn_msg签名错误或者回调通知验签不通过。原因七成情况是参与签名的参数顺序或格式不对。微信要求参数名按 ASCII 码升序排列且值为空的参数直接剔除但很多开发者在拼接时用了「顺序不变 末尾加 key」的错误思路还有一个隐蔽问题是 value 里包含转义字符比如中文内容没有转 UTF-8。解决写一个签名调试函数把待签名字符串直接打日志拿微信官方签名工具对比。特别注意如果body里有特殊字符会导致拼接串错乱建议对参数值先做urlencode再签名但微信官方没有强调这点实际测试中发现部分特殊字符确实会引发问题我一般直接限制订单标题里不能出现和。5.2 回调更新了订单前端却没跳转成功现象用户支付成功后商户后台订单状态已更新但用户浏览器还停在「扫码中」或「待支付」页面需要手动刷新。原因支付系统有两条链路——异步通知更新数据库前端「轮询查询订单状态」负责 UI 刷新。如果只在回调里更新订单前端没有轮询逻辑或者轮询间隔太久比如 10 秒一次用户体验就很差。解决前端加轮询每 3 秒调一次order_status接口成功后window.location.href跳转支付结果页。同时回调里不能把return当成直接输出给用户的响应——微信的异步通知响应是 XML 字符串输出不是 JSON写反了微信会重试但前端轮询和它无关。5.3 支付宝交易成功后订单却是「待付款」现象支付宝账单显示交易成功但本地订单状态为待付款日志里没有异步通知记录。原因notify_url配置的是内网地址或者被服务器防火墙 / 后台权限校验拦截了。支付宝的异步通知是从公网发起你的服务器必须能对外接收 POST如果用了 Nginx 且配置了 IP 白名单会把支付宝服务器 IP 挡在外面。解决先在支付宝开放平台把notify_url配置为https://你的域名/api/notify/alipay然后用curl -X POST https://你的域名/api/notify/alipay -d test1手动测一下能不能通通不了就检查 Nginx 的deny规则和 TP5 路由是否把该接口限制了。另外 TP5 的路由默认是强制路由的需要确保 notify 路由在路由列表里。5.4 沙箱环境能支付正式环境报「无效请求」现象支付宝沙箱正常切正式环境后下单直接报「isv.invalid-signature」或微信报「此商户涉嫌违规」。原因要么是换了app_id和密钥但代码里硬编码了沙箱的网关地址要么是正式环境下spbill_create_ip或body内容触发了支付渠道的风控。沙箱和正式环境的网关域名不一样支付宝沙箱是openapi.alipaydev.com正式是openapi.alipay.com很多人只改了 app_id 忘了改网关。解决把网关地址统一放进配置项用app.env区分沙箱和正式部署时只改.env文件。微信这边正式环境对body字符数和内容有限制商品标题不要出现「测试」两字否则直接拒绝。5.5 金额比对出错订单 12.1 元回调却是 12.10 元现象微信回调金额是1210分本地订单金额是12.1元用浮点比较永远不相等。原因微信传的是整数分你不会把它除 100 后直接和元比较但12.1在浮点存储里是12.099999abs(12.099999 - 12.1) 0.01不成立所以实际是能通过的。真正出问题是本地订单金额是字符串12.10强制转 float 后变成 12.1但如果是整数元和整数字符串拼接错误比如金额格式化用了number_format($amount, 1)会导致12.1变12.0。解决所有金额比较统一用「分」为整型比较本地订单存元时也转成分再比较逻辑是intval(round($order_amount * 100)) (int)$params[total_fee]。支付宝的total_amount是字符串元这个除以 1 后number_format格式化为两位小数再转字符串比较。6. 沙箱先行支付宝沙箱和微信测试号的快速验证套路正式环境联调支付最痛苦的是真的扣钱——每次测试都得退款还要等银行流水。我拆这套 TP5 支付集成时最顺手的验证路径是先沙箱、再小额、最后接口自动化回归。支付宝沙箱是最容易跑通的开放平台控制台有个「沙箱环境」页面里面有专用的 app_id、应用私钥、支付宝公钥还提供配套的买家账号和一个「沙箱工具」扫码 App。接代码时有几个固定动作第一步把gateway换成https://openapi.alipaydev.com/gateway.do第二步确认配置里的私钥、支付宝公钥都用沙箱控制台里「密钥工具」生成的那一对不要拿正式环境的第三步在沙箱里用 APP 扫描收银台页面二维码输入沙箱支付密码交易就直接完成。异步通知在这个环境也是真实的回调和正式环境唯一的区别是响应时间快一点。微信这边没有官方「沙箱」但有一个「体验号」体系在微信商户平台申请一个测试商户号配合体验 appid微信公众号的测试号用。测试号配的mch_id是特殊的下单金额有上限一般不超过 1 分钱限额但notify_url可以配置到内网穿透服务本地改代码调试也方便。测试时用一个 0.01 元的订单走完整链路下单 → 扫码 → 回调 → 查单 → 退款退款 5 分钟内到账。这一套跑通后再把配置切回正式商户号注意切换时把appid、mch_id、apiclient_cert.pem三个文件同时换掉。我建议从这套集成落地开始就养成一个习惯每天联调结束时把「下单请求参数 回调原文 验签结果」三份日志全量保留尤其是验签失败的那些每次翻车找问题几乎都靠它们。如果你也准备在自己项目里接微信支付和支付宝支付复制这套 TP5 的目录结构和验签逻辑你会发现真正花时间的地方不是代码而是那些文档里没写出来的边界参数。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →