首页 / 资讯中心 / 文章详情

ThinkPHP5 同时接入微信支付与支付宝支付:签名、回调与幂等实战

ThinkPHP5 同时接入微信支付与支付宝支付:签名、回调与幂等实战 ★ FEATURED ARTICLE
简介面向使用ThinkPHP5构建电商或在线业务、需要接入微信支付与支付宝支付的PHP开发者这份代码资料围绕支付接口集成展开涉及商户信息配置、统一下单、支付回调、签名验签以及敏感数据安全传输等关键环节并对网银支付等扩展方式作了提示。压缩包共2321个文件约4.76MB其中1545个php源码与136个phpt测试文件构成主体另含data数据、md说明、xml/yml/json配置和少量前端静态资源目录组织便于按支付模块查阅实现与测试用例。已有927人学习下载可作为对照TP5支付流程、排查配置与回调问题的参考资料对具备一定PHP基础、希望快速搭建支付功能原型或补齐安全校验思路的读者也能从中获得可复用的代码组织方式与调试线索。1. 一个下单按钮要过两套签名ThinkPHP5 里同时接微信支付和支付宝支付到底难在哪上周有人拿着一份跑不起来的代码来找我页面能打开二维码也不显示日志里就一行“签名错误”。翻了两小时才发现他把 ThinkPHP5 项目的extend目录整个挪到了vendor下命名空间没跟着改微信支付和支付宝支付的密钥配置各自从不同地方读读出来一个是空字符串一个还带着文件末尾的换行。两个渠道的支付接口难点从来不在调通单个 demo而在同一套 TP5 骨架里同时承载两套完全不同的签名规则、两套回调应答格式、两套金额单位。这份资源要解决的就是这件事把微信支付、支付宝支付、扫码与页面跳转支付收进一个 TP5 项目下单、回调、查单、退款能共用一条订单主流程。适合正在做商城、会员充值、虚拟商品站且手上已经有一个 TP5 老项目的开发者。2. 项目骨架TP5 里支付模块的目录、配置与自动加载怎么摆这套代码不引入 Composer 包去封装支付 SDK而是把渠道实现直接放进extend目录。理由很实际TP5 5.0 和 5.1 的项目里extend目录默认被注册为根命名空间只要目录层级和命名空间对得上new \pay\wechat\WechatPay()拿起来就能用不依赖composer dump-autoload部署到没有 Composer 的机器上也不会翻车。代价是你得自己守住命名约定这一点后面会反复提到。2.1 目录结构与命名空间约定先看落到磁盘上长什么样。下面这棵树是我在项目里实际用的结构控制器只负责参数校验和结果返回渠道相关的一切都收在extend/pay下project/ ├── application/ │ ├── extra/ │ │ └── pay.php # 两个渠道的配置集中在这一个文件 │ ├── index/ │ │ ├── controller/ │ │ │ ├── Pay.php # 下单、二维码、跳转 │ │ │ └── Notify.php # 回调入口必须免登录 │ │ └── service/ │ │ └── OrderService.php # 订单落库、状态推进 │ └── route.php ├── extend/ │ └── pay/ │ ├── Payment.php # 渠道门面统一对外方法 │ ├── Contract.php # 渠道接口定义 │ ├── wechat/ │ │ ├── WechatPay.php │ │ └── WxNotify.php │ └── alipay/ │ ├── AlipayPay.php │ └── AliNotify.php ├── runtime/ │ └── cert/ │ ├── apiclient_cert.pem # 微信退款用 │ ├── apiclient_key.pem │ └── alipay_public_key.pem # 支付宝公钥不是你自己的应用公钥 └── public/ └── index.php命名空间必须和目录一一对应。extend/pay/wechat/WechatPay.php里的第一行就是namespace pay\wechat;类名WechatPay。有人习惯把extend改成app\pay那就要在application下建目录两条路都行但不要混用一个类文件在extend里却声明namespace app\payTP5 的自动加载器会在application/pay/下找文件找不到就抛“类不存在”报错信息只显示类名很容易看花眼。证书文件放在runtime/cert而不是public下是因为public是 Web 根目录证书放里面等于给全网发了一份。路径在配置里写绝对路径用Env::get(root_path)拼不要写死/var/www/xxx换机器就废。2.2 配置项收敛把两套密钥证书关进一个 extra/pay.phpTP5 的application/extra/*.php会被自动加载成二级配置Config::get(pay.wechat.appid)直接取。把两个渠道写在一起好处是切换环境和排查时只需要看一个文件?php // application/extra/pay.php return [ // 对外回调域名两个渠道共用末尾不要带斜杠 notify_host https://pay.example.com, wechat [ appid wx0000000000000000, mch_id 1600000000, api_key 32位商户API密钥, // V2 签名用的 key不是 APIv3 密钥 cert_path Env::get(root_path) . runtime/cert/apiclient_cert.pem, key_path Env::get(root_path) . runtime/cert/apiclient_key.pem, gateway https://api.mch.weixin.qq.com, notify_url https://pay.example.com/index/notify/wechat, ], alipay [ app_id 2021000000000000, private_key 应用私钥去掉头尾标记后的一整行, alipay_public 支付宝公钥注意不是应用公钥, gateway https://openapi.alipay.com/gateway.do, notify_url https://pay.example.com/index/notify/alipay, return_url https://pay.example.com/index/pay/result, ], ];几个参数值得单独说。微信的api_key是商户平台里自己设置的 32 位密钥只用于 V2 的 MD5/HMAC-SHA256 签名和 APIv3 密钥完全是两码事混用会一直签名失败。支付宝的private_key建议直接存成一整行字符串不要存.pem文件再读因为读文件时file_get_contents会带上换行和空格拼进待签名字符串里必然对不上如果一定要读文件读完先preg_replace(/\s/, , $key)清洗一遍。支付宝公钥同理它是支付宝平台生成的、用来验签回调的那把很多人把应用公钥填进去结果回调验签永远返回 false。notify_host单独抽出来是因为本地开发时回调地址要指向内网穿透域名上线换成正式域名改一处就够不用在两个渠道里各改一遍。2.3 一个 Payment 门面让控制器只认业务不认渠道如果控制器里写if ($channel 1) { new WechatPay } else { new AlipayPay }业务一多就会变成一坨。项目里加了一层Contract接口和Payment门面控制器只跟门面打交道?php // extend/pay/Contract.php namespace pay; interface Contract { // 统一下单返回渠道原始结果 public function unifiedOrder(array $order); // 查单用于回调丢失后的补偿 public function queryOrder($outTradeNo); // 退款 public function refund($outTradeNo, $refundNo, $totalFee, $refundFee); // 回调验签返回布尔 public function verifyNotify(array $data); }?php // extend/pay/Payment.php namespace pay; use think\Config; class Payment { // 渠道常量订单表里存的就是这个值 const CHANNEL_WECHAT 1; const CHANNEL_ALIPAY 2; public static function driver($channel) { switch ($channel) { case self::CHANNEL_WECHAT: return new \pay\wechat\WechatPay(Config::get(pay.wechat)); case self::CHANNEL_ALIPAY: return new \pay\alipay\AlipayPay(Config::get(pay.alipay)); default: throw new \InvalidArgumentException(未知支付渠道: . $channel); } } }门面里的driver()把配置作为构造参数注入进去渠道类内部不再自己去Config::get这样单测时可以直接塞一份假配置不需要动全局状态。控制器里写Payment::driver($channel)-unifiedOrder($order)就完事后面新增渠道只需要实现Contract并在switch里加一个分支旧代码一行都不用改。接口里我特意把verifyNotify也放进来了。很多人验签逻辑写在Notify控制器里两个渠道各写一遍改一处漏一处。放进接口后回调控制器只做三件事取原始数据、调verifyNotify、把结果交给OrderService渠道差异全被挡在接口后面。3. 下单链路拆解微信 Native 扫码与支付宝页面跳转的参数怎么拼下单这一段是两个渠道差异最大的地方。微信走 XML MD5返回一个code_url让你自己生成二维码支付宝走表单 RSA2直接返回一段 HTML 表单或者一个跳转 URL。订单在这一步落库后面回调再回来更新状态所以下单代码写得好不好直接决定回调阶段要不要打补丁。3.1 微信统一下单签名、XML 与 code_url微信 V2 统一下单的完整链路是组装参数 → 字典序拼串 → MD5 加密 → 转大写 → 拼 XML → POST → 解析返回 XML → 取code_url。签名函数是整个模块的地基下面这份是我改过好几版之后留下的?php // extend/pay/wechat/WechatPay.php namespace pay\wechat; use pay\Contract; class WechatPay implements Contract { protected $config; public function __construct(array $config) { $this-config $config; } /** * 生成 V2 签名 * param array $params 参与签名的参数不含 sign * return string 32 位大写 MD5 */ public function makeSign(array $params) { unset($params[sign]); // 注意不能用 array_filter 默认回调它会把 0 和 0 一起干掉 $params array_filter($params, function ($v) { return $v ! $v ! null; }); ksort($params); // 字典序 $pairs []; foreach ($params as $k $v) { $pairs[] $k . . $v; } $stringA implode(, $pairs); $stringSignTemp $stringA . key . $this-config[api_key]; return strtoupper(md5($stringSignTemp)); // 必须大写 } /** * 数组转微信要求的 XML */ protected function toXml(array $data) { $xml xml; foreach ($data as $k $v) { $xml . is_numeric($v) ? {$k}{$v}/{$k} : {$k}![CDATA[{$v}]]/{$k}; } return $xml . /xml; } /** * XML 转数组关闭外部实体防 XXE */ protected function fromXml($xml) { libxml_disable_entity_loader(true); $obj simplexml_load_string($xml, SimpleXMLElement, LIBXML_NOCDATA); if ($obj false) { throw new \Exception(微信返回报文解析失败); } return json_decode(json_encode($obj), true); }签名函数里那个array_filter的坑值得展开说。微信的参数字典序拼接要求剔除空值但如果直接用array_filter($params)PHP 默认回调会把字符串0、数字0、空数组都当成 false 过滤掉。订单金额、total_fee一般不会是 0但settlement_id、goods_tag这类可选字段传了 0 就会凭空消失导致签名和微信服务端算出来的不一致报“签名错误”却看不出哪里少了一个键。用显式回调只过滤和null才是安全的做法。toXml里对数字不加 CDATA、对字符串加 CDATA是因为微信对total_fee、cash_fee这类整数字段要求是数字节点包了 CDATA 某些场景下会解析异常。fromXml里LIBXML_NOCDATA必须带上否则 CDATA 会被解析成一个对象而不是字符串取code_url时会拿到SimpleXMLElement实例json_encode之后变成{0:weixin://...}前端二维码直接生成失败。真正发起请求的部分public function unifiedOrder(array $order) { $params [ appid $this-config[appid], mch_id $this-config[mch_id], nonce_str md5(uniqid(, true)), body $order[subject], out_trade_no $order[out_trade_no], total_fee $order[total_fee], // 单位分 spbill_create_ip $order[client_ip], notify_url $this-config[notify_url], trade_type NATIVE, product_id $order[out_trade_no], // NATIVE 必填 time_expire date(YmdHis, time() 1800), ]; $params[sign] $this-makeSign($params); $xml $this-toXml($params); $url $this-config[gateway] . /pay/unifiedorder; $resp $this-postXml($url, $xml); // 见 3.3 的 curl 封装 $result $this-fromXml($resp); if ($result[return_code] ! SUCCESS) { throw new \Exception(微信通信失败: . $result[return_msg]); } if ($result[result_code] ! SUCCESS) { throw new \Exception(微信下单失败: . $result[err_code_des]); } return $result; // 关键字段code_url、prepay_id }参数上的几个细节spbill_create_ip必须是终端用户 IP不是服务器 IP用Request::instance()-ip()取别写死127.0.0.1否则微信风控可能拒单total_fee单位是分订单表如果按元存这里一定要乘 100 并取整浮点数运算出来的 99.999999 会导致金额校验失败time_expire给 30 分钟配合后面订单关闭任务一起用。返回值里code_url只在NATIVE模式下有把它丢给前端用qrcode.js生成二维码即可页面上不用再调一次微信接口。3.2 支付宝电脑网站支付biz_content 与 RSA2支付宝的签名规则和微信完全不同参数按字典序拼接后不追加密钥而是用应用私钥做 SHA256 加密再 base64验签用支付宝公钥。biz_content必须是一段 JSON 字符串且签名时用的就是这段原始字符串拼接前不能重复json_encode也不能先urlencode?php // extend/pay/alipay/AlipayPay.php namespace pay\alipay; use pay\Contract; class AlipayPay implements Contract { protected $config; public function __construct(array $config) { $this-config $config; } /** * 生成 RSA2 签名 * param array $params 公共参数 biz_content */ public function buildSign(array $params) { unset($params[sign]); ksort($params); $pairs []; foreach ($params as $k $v) { if ($v || $v null) { continue; } $pairs[] $k . . $v; // 这里是原始值不做 urlencode } $stringToBeSigned implode(, $pairs); $sign ; $ok openssl_sign( $stringToBeSigned, $sign, $this-formatKey($this-config[private_key], PRIVATE), OPENSSL_ALGO_SHA256 ); if (!$ok) { throw new \Exception(支付宝签名失败检查应用私钥); } return base64_encode($sign); } /** * 把不带头尾标记的裸密钥补成 PEM 格式 */ protected function formatKey($key, $type) { $key preg_replace(/\s/, , $key); $body chunk_split($key, 64, \n); return $type PRIVATE ? -----BEGIN RSA PRIVATE KEY-----\n{$body}-----END RSA PRIVATE KEY-----\n : -----BEGIN PUBLIC KEY-----\n{$body}-----END PUBLIC KEY-----\n; }formatKey这段是血泪经验支付宝后台下载的密钥文件是带-----BEGIN RSA PRIVATE KEY-----头的但很多人复制的时候只复制了中间那段 base64直接丢进openssl_sign会报“提供的密钥参数不能强制转换为私钥”。统一清洗空白再自己补头尾比每次手动处理可靠。另外 PKCS1 和 PKCS8 两种格式头不一样如果用的是BEGIN PRIVATE KEY开头的 PKCS8前缀要相应换掉否则同样报错。下单部分电脑网站支付本质是拼一个带签名的 GET URL 让浏览器跳过去public function unifiedOrder(array $order) { $bizContent [ out_trade_no $order[out_trade_no], total_amount number_format($order[total_fee] / 100, 2, ., ), subject $order[subject], product_code FAST_INSTANT_TRADE_PAY, timeout_express 30m, ]; $params [ app_id $this-config[app_id], method alipay.trade.page.pay, format JSON, 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($bizContent, JSON_UNESCAPED_UNICODE), ]; $params[sign] $this-buildSign($params); // 注意拼 URL 时才 urlencode签名阶段用的是原始值 return $this-config[gateway] . ? . http_build_query($params); }金额单位在这里翻了个个儿微信是分支付宝是元且必须两位小数的字符串。number_format($fen / 100, 2, ., )不能省99.9和99.90在支付宝那边是两回事金额校验会挂。http_build_query自带 urlencode所以biz_content在签名阶段保持 JSON 原样只在最后拼 URL 时编码一次。如果你先urlencode再签名支付宝服务端拿到的是编码后的字符串去解码再拼两边算出来的签名必然不同。3.3 订单表设计两套渠道共用一张表要预留哪些字段两个渠道的字段名对不上微信叫out_trade_no、transaction_id、total_fee支付宝叫out_trade_no、trade_no、total_amount。共表的关键是统一内部口径渠道字段只做映射不做存储CREATE TABLE pay_order ( id int unsigned NOT NULL AUTO_INCREMENT, out_trade_no varchar(32) NOT NULL COMMENT 商户订单号两渠道共用, channel tinyint NOT NULL DEFAULT 0 COMMENT 1微信 2支付宝, trade_type varchar(16) NOT NULL DEFAULT COMMENT NATIVE/PAGE/WAP, subject varchar(128) NOT NULL DEFAULT , total_fee int unsigned NOT NULL DEFAULT 0 COMMENT 金额统一存分, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已退款 3已关闭, transaction_id varchar(64) NOT NULL DEFAULT COMMENT 渠道流水号, notify_data text COMMENT 原始回调报文排障用, paid_at int unsigned NOT NULL DEFAULT 0, created_at int unsigned NOT NULL DEFAULT 0, updated_at int unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_out_trade_no (out_trade_no), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;total_fee统一存分是这套设计的核心决定。两个渠道展示层各自转换微信直接用支付宝除 100。如果反过来按元存 DECIMAL微信下单时乘以 100 会引入浮点误差而且数据库里一个字段两种语义接手的人根本分不清。notify_data存原始报文看着浪费空间但线上出现“用户说付了钱订单没变”的时候把这段 XML 或表单打印出来五分钟就能定位是验签没过还是业务处理抛异常比翻日志快得多。out_trade_no上的唯一索引是后面幂等更新的前提没有它重复回调会插出两条订单。4. 异步回调验签、幂等和订单状态机的完整落地用户付完钱之后页面跳转回return_url只是展示用的真正决定订单状态的是服务端收到的异步通知。这一段是整个支付集成里最容易出线上事故的地方验签漏了会被伪造通知刷单幂等没做会重复发货应答格式写错会导致渠道一直重推。4.1 微信回调 XML 验签与 return_code 应答微信回调是 POST 一段 XML 到notify_urlContent-Type 是text/xml。TP5 里取原始报文必须用file_get_contents(php://input)不能用Request::instance()-param()因为 TP5 会尝试解析请求体XML 解析不出来就返回空数组你会以为是微信没发数据?php // application/index/controller/Notify.php namespace app\index\controller; use think\Controller; use think\Log; use pay\Payment; use app\index\service\OrderService; class Notify extends Controller { // 回调入口必须免登录且不能走任何鉴权中间件 protected $middleware []; public function wechat() { $raw file_get_contents(php://input); Log::write(微信回调原始报文: . $raw, info); $driver Payment::driver(Payment::CHANNEL_WECHAT); try { $data $driver-parseNotify($raw); // 内部做 fromXml 验签 } catch (\Exception $e) { Log::write(微信回调验签失败: . $e-getMessage(), error); return $this-wxReply(FAIL, 验签失败); } // 通信标识和业务标识都要判只看一个会漏 if ($data[return_code] ! SUCCESS || $data[result_code] ! SUCCESS) { return $this-wxReply(SUCCESS, OK); // 业务失败也要应答 SUCCESS避免无意义重推 } OrderService::handlePaid( $data[out_trade_no], $data[transaction_id], (int)$data[total_fee], $raw ); return $this-wxReply(SUCCESS, OK); } protected function wxReply($code, $msg) { return xmlreturn_code![CDATA[ . $code . ]]/return_code . return_msg![CDATA[ . $msg . ]]/return_msg/xml; } }验签的实现在渠道类里规则和下单时一致把回调数组剔除sign后重算一遍比对public function parseNotify($rawXml) { $data $this-fromXml($rawXml); if (!isset($data[sign])) { throw new \Exception(回调缺少 sign 字段); } $sign $data[sign]; $calc $this-makeSign($data); // makeSign 内部会 unset sign if ($calc ! $sign) { throw new \Exception(回调签名不匹配); } return $data; }应答格式是纯 XML 字符串不能json()返回。微信只看return_code是不是SUCCESS如果不是会按 15 秒、15 秒、30 秒、3 分钟、10 分钟……的间隔重推最多 8 次左右。所以业务处理哪怕失败只要报文本身合法也应该回SUCCESS把失败记到自己的补偿队列里别指望微信一直推。反过来如果你处理成功了但应答格式写错微信会一直重推日志里就会看到同一笔订单被处理七八次这就是幂等必须做的原因。4.2 支付宝回调 RSA2 验签与 success 应答支付宝回调是标准 POST 表单$_POST里直接能拿到out_trade_no、trade_no、trade_status、total_amount、sign、sign_type。验签时要把sign和sign_type两个字段剔除其余全部参与拼接包括charset、version这些看起来无关的public function parseNotify(array $post) { if (empty($post[sign])) { throw new \Exception(回调缺少 sign); } $sign $post[sign]; $signType isset($post[sign_type]) ? $post[sign_type] : RSA2; // 这两个字段不参与验签 unset($post[sign], $post[sign_type]); ksort($post); $pairs []; foreach ($post as $k $v) { if ($v || $v null) { continue; } $pairs[] $k . . $v; } $str implode(, $pairs); $algo $signType RSA2 ? OPENSSL_ALGO_SHA256 : OPENSSL_ALGO_SHA1; $ok openssl_verify( $str, base64_decode($sign), $this-formatKey($this-config[alipay_public], PUBLIC), $algo ); if ($ok ! 1) { throw new \Exception(支付宝回调验签失败); } return $post; }回调控制器里验签通过之后还要校验三件事缺一个都可能被钻空子public function alipay() { $post input(post.); $driver Payment::driver(Payment::CHANNEL_ALIPAY); try { $data $driver-parseNotify($post); } catch (\Exception $e) { Log::write(支付宝验签失败: . $e-getMessage(), error); return fail; // 返回非 success支付宝会重推 } // 1. app_id 必须是自己应用的 if ($data[app_id] ! Config::get(pay.alipay.app_id)) { return fail; } // 2. 交易状态只认这两个WAIT_BUYER_PAY 之类不能发货 if (!in_array($data[trade_status], [TRADE_SUCCESS, TRADE_FINISHED], true)) { return success; } try { OrderService::handlePaid( $data[out_trade_no], $data[trade_no], (int)round($data[total_amount] * 100), // 元转分 json_encode($data, JSON_UNESCAPED_UNICODE) ); } catch (\Exception $e) { Log::write(支付宝订单处理失败: . $e-getMessage(), error); return fail; // 让支付宝重推别吞掉 } return success; // 必须是小写的纯字符串 }应答就是echo success不能带引号、不能带 HTML、不能是SUCCESS大写。支付宝官方文档里写得很清楚但翻车的人不少原因通常是控制器的返回被全局的响应中间件包了一层 JSON。解决办法是在这个方法里直接return response(success, 200, [], text)或者最粗暴地exit(success)先把链路跑通再优化。4.3 幂等更新一条 UPDATE 带 where status0 解决重复通知不管哪个渠道回调都可能重复到达。幂等的做法不是先SELECT再UPDATE那样在并发下有两个请求同时查到status0都会去发货。正确的姿势是把状态判断塞进UPDATE的WHERE里用影响行数判断自己是不是第一个?php // application/index/service/OrderService.php namespace app\index\service; use think\Db; use think\Log; class OrderService { public static function handlePaid($outTradeNo, $transactionId, $notifyFee, $raw) { $order Db::name(pay_order)-where(out_trade_no, $outTradeNo)-find(); if (!$order) { throw new \Exception(订单不存在: . $outTradeNo); } // 金额必须核对防止篡改通知刷低价商品 if ((int)$order[total_fee] ! (int)$notifyFee) { Log::write(金额不一致 out{$outTradeNo} db{$order[total_fee]} notify{$notifyFee}, error); throw new \Exception(回调金额与订单金额不一致); } // 核心状态条件写进 WHERE靠影响行数判断是否首次处理 $affected Db::name(pay_order) -where(out_trade_no, $outTradeNo) -where(status, 0) -update([ status 1, transaction_id $transactionId, notify_data $raw, paid_at time(), updated_at time(), ]); if ($affected 0) { Log::write(重复回调已忽略 out{$outTradeNo}, info); return false; } // 只有真正把状态从 0 推到 1 的那一次才触发发货/加余额 self::deliver($order); return true; } }这段代码的关键在于where(status, 0)和$affected 0的判断顺序。重复回调进来时第一次已经把status改成 1第二次的UPDATE匹配不到行影响行数为 0直接返回不会重复发货。这比加锁简单也比 Redis 分布式锁可靠因为判断和写入在同一条 SQL 里完成天然原子。金额核对那一行也别省。微信回调用total_fee分支付宝回调用total_amount元转换之后统一按分比对。曾经遇到过测试环境把订单金额改成 1 分钱下单回调里带的是真实金额如果代码不校验金额直接按订单金额发货就等于白送。状态机只允许0→1、1→2退款两个方向其他跳转一律拒绝status字段上别做万能更新。5. 排查手册签名失败、回调不进门、金额对不上这三类坑线上跑起来之后出问题的地方高度集中。下面这几条是我在不同项目里反复遇到的每条按“现象、原因、解决”记遇到同类问题可以照着对。5.1 签名类问题报文一个字都不能差现象微信下单返回签名错误err_code是SIGNERROR把参数打印出来逐个核对看着都对着呢。原因十有八九是参与签名的字段集合和实际发送的字段集合不一致。常见的有三种一是array_filter默认回调把0值的字段过滤掉了发送时又带上了二是nonce_str生成之后重新赋值了一次签名用的是旧值三是body字段里有中文或空格签名时是原文拼 XML 时被 CDATA 包起来微信服务端解析后是一致的但如果中间经过了urlencode再urldecode字节就变了。解决在makeSign前后各打一条日志把$stringSignTemp完整打印出来注意密钥别打然后去微信商户平台的签名校验工具里贴同样的串对比。养成习惯先ksort和过滤再赋值给$params[sign]赋值之后这个数组就不能再改了发送的 XML 直接由这个数组生成保证签名和发送同源。现象支付宝返回invalid-signature或者回调验签一直 false。原因支付宝公钥填错是最常见的很多人填了自己生成的应用公钥。另一类是biz_content在签名前被json_encode了两次或者JSON_UNESCAPED_UNICODE选项前后不一致导致中文被转义成\uXXXX签名串和支付宝服务端算的不一样。解决确认配置里alipay_public是支付宝开放平台“支付宝公钥”那一栏的内容不是“应用公钥”。biz_content只json_encode一次把结果存到变量里签名和拼 URL 都用这同一个变量中间不要做任何字符串处理。验签失败时把$str打出来长度和字段数一眼能看出少没少东西。5.2 回调类问题地址对了也可能收不到现象用户付款成功订单状态一直是待支付日志里没有任何回调记录。原因notify_url被路由或中间件拦了。TP5 的route.php里如果配了全局的登录校验中间件回调请求没有 session会被重定向到登录页渠道那边收到的响应不是预期的 XML/success就一直重推然后放弃。另一个原因是notify_url指向了内网地址或者带了端口渠道服务端访问不到。解决回调控制器里显式写protected $middleware [];并且在route.php里为回调路径单独配置规则不挂任何鉴权。用外网能访问的域名本地开发时把notify_host指向内网穿透地址。上线前用 curl 直接 POST 一个构造的报文到回调地址看返回的是不是xmlreturn_code或success能通再让真实支付走一遍。现象日志里同一笔订单的回调出现了七八次每次都走了发货逻辑。原因幂等没做或者做了但判断方式不对。见过先find查状态再save的写法两个回调并发进来都查到status0都执行了发货。解决按 4.3 的写法把status0塞进UPDATE的WHERE用影响行数判断。另外发货逻辑要放在UPDATE成功之后不要放在前面。5.3 金额与订单类问题差一分钱也是事故现象微信下单报金额格式错误或者支付宝报total_amount参数非法。原因单位搞混。微信total_fee必须是整数分传了99.00这种带小数点的字符串直接拒支付宝total_amount必须是两位小数的字符串传了整数99有时能过有时不过取决于金额大小。解决订单表统一存分整数出站时按渠道转换微信直接(int)$fen支付宝number_format($fen / 100, 2, ., )。回调入站时反向转换微信直接取支付宝(int)round($amount * 100)。所有转换封装到渠道类里业务层只认分不要在两处各写一遍。现象查单接口返回已支付但本地订单还是待支付。原因回调丢失。用户付款时网络抖动、服务器正好重启、回调地址短时间不可用都可能导致渠道的重推全部失败。解决加一个定时补偿任务扫status0且创建超过 5 分钟的订单调渠道的queryOrder主动查一次查到已支付就复用handlePaid的逻辑推状态。查单接口两个渠道都有微信/pay/orderquery支付宝alipay.trade.query。这个任务不需要很频繁五分钟跑一次就够但必须有否则丢一单就要人工介入。6. 二次开发加渠道、接退款、做对账的改造顺序拿到这份代码之后想往上加东西顺序别搞反。第一步永远是补订单表不是写渠道类。加一个新渠道比如某银行 H5 支付先在pay_order的channel里加一个常量确认out_trade_no、transaction_id、total_fee三个字段能映射过去再看这个渠道的回调是同步跳转还是异步通知异步的话应答格式是什么。这三件事想清楚写Contract实现就是填空题。退款是第二块。微信退款走/secapi/pay/refund需要双向证书支付宝走alipay.trade.refund只要私钥。两边都要传自己的退款单号out_refund_no且同一笔订单多次部分退款时这个号必须每次都不同否则微信会返回“退款单号重复”。退款结果同样是异步通知微信发到notify_url支付宝发到refund_notify_url可以和应用回调同地址靠refund_fee字段区分。建议单独建一张pay_refund表out_trade_no做普通索引out_refund_no做唯一索引状态机和支付表保持一致的写法。对账是第三块也是最能体现这套结构价值的地方。两个渠道都提供对账单下载微信/pay/downloadbill返回 CSV需要证书支付宝用alipay.data.dataservice.bill.downloadurl.query拿到一个临时下载链接。对账任务的逻辑是每天凌晨拉前一天的账单按out_trade_no跟本地pay_order做全量比对分出三类本地有渠道无可能掉单、渠道有本地无可能回调丢了且查单也没补上、金额不一致。第三类直接报警前两类自动补状态。// 对账核心按 out_trade_no 建索引后做差集别用双层循环 $localMap Db::name(pay_order) -whereBetween(created_at, [$start, $end]) -where(status, 1) -column(total_fee, out_trade_no); $billMap []; // 从 CSV 或账单接口解析出来 foreach ($billRows as $row) { $billMap[$row[out_trade_no]] (int)$row[total_fee]; } // 渠道有、本地无 或 金额不等 foreach ($billMap as $no $fee) { if (!isset($localMap[$no]) || $localMap[$no] ! $fee) { Log::write(对账差异 out{$no} bill{$fee}, error); } }这段代码没什么花哨的但用column()先把本地数据拉成一个以订单号为键的数组再单层遍历账单几万条数据秒级完成。早些年我写过双层foreach的版本二十万条跑了七分钟还被同事笑了很久。最后说个改造时的验证方法每加一个渠道或一种支付方式别只测正常支付。我一般的顺序是——先构造一笔金额 0.01 的单走通全流程再用工具重放回调报文十次验证幂等然后手动把notify_url改成不可达地址付一笔等五分钟看补偿任务能不能把状态补上最后下载对账单跑一次比对确认没有差异。这四步走完这个渠道才算接住。从那以后我每次接新的支付渠道都会强制走一遍这套流程哪怕是已经接过很多次的渠道也不敢跳过因为翻车往往就翻在“这次应该一样”的自信上。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站