
简介为ThinkPHP5项目打造的微信与支付宝支付集成资源包主要面向需要快速接入主流支付渠道的PHP开发者。资源完整呈现商户配置、SDK调用、下单支付、回调验签等关键环节同时兼顾网银支付对接思路。压缩包共2321个文件其中PHP源码1545个另含配置文件、测试脚本与说明文档整体仅4.76MB便于部署和二次开发。目前已有925人学习适合作为支付接口开发的自学参考或项目模板。内容针对微信支付、支付宝支付分别梳理商户号、密钥、回调地址等配置方式并详解统一下单、预支付标识生成、支付结果通知等流程作者还总结了安全传输与签名校验的注意事项。资源内附带phpunit测试用例和配置模板可帮助开发者快速验证支付逻辑目录结构清晰便于按支付渠道分类检索能有效缩短开发周期、降低接入门槛。无论是个人开发者自学还是团队集成复用都能从中受益。1. 把微信支付和支付宝支付接进TP5真正难的不是调接口做商城项目的时候我把微信支付和支付宝支付接进TP5后台前前后后折腾了快两周。官方给的旧版Demo要么停留在ThinkPHP 3时代要么引一堆SDK依赖都塞不进现有的老项目。这份项目把支付收口成一个TP5扩展业务层只调Pay::driver(wechat)-pay($order)这一行统一下单、签名、异步回调、退款全部收敛进驱动。微信、支付宝、网银支付三种方式共用同一套业务接口几百行代码替代官方文档的几十页说明。实际接完才会发现真正花时间的不是调接口而是签名格式、证书、回调这三个环节——这套代码把三座山挪走了。适合手上有TP5老项目要做支付集成、或者外包商城要快速加收银台的开发新手按章节顺序走一遍也能跑通。2. 架构设计驱动模式把微信、支付宝、网银收进同一套接口2.1 目录结构与加载方式extend 下怎么让 TP5 自动识别这套代码没有把支付逻辑平铺在两个控制器里而是在extend目录下做了一套轻量驱动框架。ThinkPHP 5 的extend目录自带命名空间自动加载能力只要类名和文件路径对齐控制器里use pay\Pay;就能直接实例化。目录结构大致长这样extend/pay/ ├── Pay.php // 统一入口负责驱动分发 ├── Driver.php // 驱动抽象类定义支付、退款、回调、查询四个契约 ├── exception/ │ └── PayException.php // 支付异常统一抛出 └── driver/ ├── Wechat.php // 微信支付驱动统一下单、XML 解析、退款证书 ├── Alipay.php // 支付宝驱动页面支付、RSA2 签名、表单跳转 └── Bank.php // 网银支付驱动表单 POST 跳转类渠道的示例 application/ ├── config/pay.php // 微信、支付宝、网银的商户配置 ├── controller/Payment.php // 收银台、回调入口、退款入口 └── model/Order.php // 订单模型金额单位统一为分看目录就能明白它的设计意图业务层只依赖pay\Pay不直接 new 任何一个渠道类。装新渠道就是往driver/目录丢一个类再改配置订单控制器不需要动。这种分层对老项目改造特别友好我的习惯是先跑通一个渠道再复制驱动模板基本不会把现有业务拖下水。2.2 统一入口与驱动抽象业务层永远只调 Pay::driver()核心的分发逻辑在extend/pay/Pay.php整个类只有十几行做两件事拼接驱动类名、单例缓存实例。用ucfirst($name)把wechat变成Wechat再映射到\pay\driver\Wechat这个类存在则复用不存在就抛异常。?php namespace pay; class Pay { protected static $drivers []; public static function driver($name) { $class \\pay\\driver\\ . ucfirst($name); if (!class_exists($class)) { throw new \InvalidArgumentException(支付驱动 {$name} 不存在); } if (!isset(self::$drivers[$name])) { self::$drivers[$name] new $class(); } return self::$drivers[$name]; } }参数说明$name是配置里约定的渠道标识wechat、alipay、bank三选一实际项目里也可以让客户端传一个channel字段收银台拿到后调用Pay::driver($channel)前端和后端都只认字符串不感知具体类。单例缓存的意义在于同一个请求里多次调用Pay::driver(wechat)不会重复初始化对象和加载证书。Driver.php是抽象基类它把支付渠道的差异收敛成四个动词下单pay、退款refund、异步通知notify、主动查单query。每个驱动必须实现这四个方法但对订单模块而言它永远只面对这四个接口签名。?php namespace pay; abstract class Driver { protected $config; public function __construct(array $config []) { $this-config $config; } abstract public function pay(array $order); abstract public function refund(array $order); abstract public function notify(); abstract public function query(array $order); }参数说明$order是业务层传入的订单数据约定至少包含order_no商户订单号、amount金额单位分整数、subject商品标题。金额在驱动入口统一为分微信直接用支付宝在内部除以 100 转成元。这个约定是后来被金额差 100 倍坑过之后才立下的规矩。2.3 协议差异收敛XML、表单跳转、验签方式的统一策略微信走的是 POST XML支付宝走的是 GET 表单自动提交网银又是另一套 POST 表单跳转协议。业务层不可能为每个渠道写一套收银台模板所以驱动内部必须把差异吞掉。三个渠道的现实差异先列清楚维度微信支付支付宝网银支付示例下单通信POST XML 到统一下单接口URL 参数签名跳转网关表单 POST 跳转银行网关签名算法MD5 或 HMAC-SHA256RSA2SHA256withRSA银行各自定义多为 MD5金额单位分元两位小数分或元需配置确认异步通知POST XMLPOST 表单参数同步 异步双通知退款需加载商户证书仅签名无需证书部分银行需要证书支付成功后统一返回一个数组[pay_url 二维码内容或跳转地址, type wechat]。收银台只需要判断type决定展示二维码还是直接window.location跳转。回调入口也做了同样的事控制器从路由参数里读type调用对应驱动的notify()驱动内部解析原始数据、验签、归一化成统一的订单状态结构再交给业务层处理。这样一来就算哪天要接第五个支付渠道收银台和订单模块一行都不用改。3. 核心实现拆解统一下单、RSA2签名到异步回调的完整链路3.1 微信统一下单参数组包、MD5 签名与 XML 通信微信支付的关键在统一下单接口所有trade_type的入口都是它。Native 扫码、JSAPI 公众号支付、H5 支付只是参数和后续跳转方式不同下单这一步完全共用。下面的代码是driver/Wechat.php里pay()的骨架public function pay(array $order) { $params [ appid $this-config[appid], mch_id $this-config[mch_id], nonce_str $this-nonceStr(), body $order[subject], out_trade_no $order[order_no], total_fee $order[amount], // 单位分 spbill_create_ip request()-ip(), notify_url $this-config[notify_url], trade_type $this-config[trade_type], // NATIVE / JSAPI product_id $order[order_no], ]; $params[sign] $this-sign($params); $xml $this-arrayToXml($params); $response $this-postXml($this-unifiedOrderUrl, $xml); $result $this-xmlToArray($response); if ($result[return_code] ! SUCCESS || $result[result_code] ! SUCCESS) { throw new \Exception(微信下单失败 . $result[return_msg]); } // NATIVE 模式下返回 code_url用它生成二维码给用户扫 return [pay_url $result[code_url], type wechat]; }sign()方法的实现值得单独说。微信的签名规则是所有非空参数按键名 ASCII 升序排列拼成key1value1key2value2末尾再拼上key商户密钥做 MD5 后转大写。注意参与签名的值不能做 URL 编码密钥也不是配置里的 APIv3 密钥而是 V2 接口的 API 密钥在商户平台「API 安全」里设置。protected function sign(array $params) { ksort($params); $string ; foreach ($params as $key $value) { if ($value || $value null) { continue; } $string . $key . . $value . ; } $string rtrim($string, ) . key . $this-config[key]; return strtoupper(md5($string)); }签名踩得最多的坑是nonce_str每次都一样微信会直接拒绝另外arrayToXml时必须把sign节点放在最后否则部分版本的服务端校验会报签名错误。XML 解析用simplexml_load_string($response, SimpleXMLElement, LIBXML_NOCDATA)不然CDATA包裹的return_msg会丢内容这是排查支付失败订单时最容易忽略的细节。3.2 支付宝页面支付RSA2 签名串与跳转 URL 的生成支付宝的电脑网站支付走的是alipay.trade.page.pay下单本质上就是拼一个带签名的跳转 URL。和微信最大的区别是参数拼进 URL 之前要签名签名基于原始字符串不是编码后的字符串。驱动里的pay()大致是这副样子public function pay(array $order) { $bizContent [ out_trade_no $order[order_no], total_amount number_format($order[amount] / 100, 2, ., ), subject $order[subject], product_code FAST_INSTANT_TRADE_PAY, ]; $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($bizContent, JSON_UNESCAPED_UNICODE), ]; $params[sign] $this-rsa2Sign($params); return [pay_url $this-gateway . ? . http_build_query($params), type alipay]; }这里有两个细节会在联调时反复折磨人。第一金额必须是元且最多两位小数所以total_amount amount / 100之后要number_format直接除再拼字符串会出浮点精度问题比如 0.29 变成 0.29000000000000004。第二biz_content用JSON_UNESCAPED_UNICODE编码如果中文被转成\uXXXX签名串和实际请求参数对不上验签必失败。rsa2Sign()的拼装要求和微信完全不同。支付宝要求所有参数按 ASCII 排序过滤空值拼成key1value1key2value2然后直接用私钥做OPENSSL_ALGO_SHA256签名。这里不能用http_build_query的输出直接签因为它的空格会被编码成而支付宝验签时会把空格还原成%20两边一对比就出错。我在驱动里是手工遍历参数拼原串保证和验签侧完全一致。protected function rsa2Sign(array $params) { ksort($params); $string ; foreach ($params as $key $value) { if ($value || $value null) { continue; } $string . $key . . $value . ; } $string rtrim($string, ); openssl_sign($string, $sign, $this-config[private_key], OPENSSL_ALGO_SHA256); return base64_encode($sign); }配置里的private_key是商户应用私钥public_key是支付宝公钥两个都是 PEM 格式字符串。私钥要注意是 PKCS1 还是 PKCS8商户后台下载时选错了openssl_sign直接返回 false而且不报任何 PHP 异常。我一般写配置时顺手在注释里标清楚格式避免换机器部署时玄学报错。3.3 异步回调处理验签、金额比对与幂等更新支付成功之后微信和支付宝都会向notify_url发异步通知。这是整个支付集成里最容易翻车的部分验签没过、金额没比对、回调重复到达、更新顺序错了都会导致订单状态混乱。驱动统一提供一个notify()把两家渠道的差异在入口处理掉业务层看到的已经是清洗后的数据。public function notify() { // 微信是 XML支付宝是 POST 表单驱动内各自解析成 $data if (!$this-verifySign($data)) { return FAIL; } // 归一化无论微信还是支付宝最终都有 out_trade_no 和 amount(单位分) $orderNo $data[out_trade_no]; $amount $data[amount]; Db::startTrans(); try { $order Db::name(orders) -where(order_no, $orderNo) -lock(true) -find(); if (!$order || $order[status] 1) { Db::commit(); return $this-successText(); // 已处理的订单直接返回成功渠道方停止重发 } if ($order[amount] ! $amount) { throw new \Exception(金额不一致订单 {$orderNo} 应收 {$order[amount]} 实收 {$amount}); } Db::name(orders)-where(order_no, $orderNo)-update([ status 1, paid_at time(), ]); Db::commit(); return $this-successText(); } catch (\Exception $e) { Db::rollback(); \think\Log::record(支付回调异常 . $e-getMessage(), pay); return $this-failText(); } }lock(true)是 TP5 查询构造器里的排他锁配合事务使用能在并发场景下避免两个回调同时读到“待支付”状态。实际生产中渠道方超时后会重发通知同一个订单可能收到三五次回调没有锁的话第一次更新后第二次又进事务库存逻辑容易被重复执行。加了锁之后后到的请求会等第一个事务提交再读到status 1直接返回成功这就是幂等处理的核心思路。verifySign在微信侧是重新算一遍签名比对支付宝侧是用支付宝公钥验签。这里要特别注意验签原始数据是渠道方发过来的原始报文不能拿 PHP 解析后的参数重新拼接再去验微信的验签要用原始 XML 里所有字段重新组包支付宝则用原始 POST 参数按签名规则拼串。我见过有人把解析后的数组json_encode再去验签结果自然是 FAIL。3.4 退款与订单查询证书加载和回滚思路退款接口的差异在微信和支付宝之间特别明显。支付宝退款只是再签一次名走alipay.trade.refund和下单几乎同构微信退款必须加载商户证书否则接口直接报“证书序列号不存在”。微信退款的驱动代码需要额外处理 curl 的证书参数public function refund(array $order) { $params [ appid $this-config[appid], mch_id $this-config[mch_id], nonce_str $this-nonceStr(), out_trade_no $order[order_no], out_refund_no $order[refund_no], // 退款单号业务侧生成 total_fee $order[amount], refund_fee $order[refund_amount], ]; $params[sign] $this-sign($params); $xml $this-arrayToXml($params); $response $this-postXml($this-refundUrl, $xml, [ CURLOPT_SSLCERT $this-config[cert_path], CURLOPT_SSLKEY $this-config[key_path], ]); // 解析 response校验 return_code / result_code然后记录 flow_no }cert_path指apiclient_cert.pemkey_path指apiclient_key.pem两份文件建议放在项目cert/目录下用ROOT_PATH拼绝对路径。证书有效期通常是 5 年但我在维护项目时发现财务在商户平台重新下载证书后只更新了本地后台没同步服务器结果生产退款挂了半天。退款接口没有异步通知必须靠返回的result_code判断结果所以退款状态要么主动调用查询接口对账要么在失败时把接口返回值完整记日志。这套驱动里query()就是为对账准备的按订单号查渠道真实状态回写本地表。4. 部署与联调密钥配置、数据表和一次真实支付测试4.1 配置文件与密钥准备项目里的application/config/pay.php是所有渠道参数的集中地。配置文件建议不要写进版本库至少私钥部分要单独用config_pay.php.local这类文件覆盖。字段结构如下return [ wechat [ appid wx1234567890abcdef, // 公众号/小程序 AppID mch_id 1600000000, // 微信商户号 key V2接口密钥32位字符串, // 商户平台 API 安全里设置 trade_type NATIVE, // NATIVE 扫码 / JSAPI 公众号 notify_url https://pay.example.com/index/payment/notify/type/wechat, cert_path ROOT_PATH . cert/apiclient_cert.pem, key_path ROOT_PATH . cert/apiclient_key.pem, ], alipay [ app_id 2021000000000000, private_key -----BEGIN RSA PRIVATE KEY-----\n..., public_key -----BEGIN PUBLIC KEY-----\n..., gateway https://openapi.alipay.com/gateway.do, notify_url https://pay.example.com/index/payment/notify/type/alipay, return_url https://pay.example.com/index/payment/return, ], ];notify_url必须是一个外网能访问到的 HTTPS 地址微信支付侧会校验收到的回调地址不能是 IP也不能是没备案的域名。支付宝的沙箱环境网关换成https://openapi.alipaydev.com/gateway.do密钥也要在沙箱应用里单独生成和正式环境完全不互通。配置完成后写一个小脚本把密钥内容打印出来前几位和后几位确认换行、头尾标记都完整再进联调。4.2 订单、流水与日志表设计支付集成不能只盯着一张订单表至少要有订单、流水、日志这三层数据。订单表只记录业务状态流水表记录渠道侧的交易号、回调原文和验签结果日志表给排查用。精简后的建表语句能覆盖回调、退款、对账三类场景CREATE TABLE orders ( id int(11) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号, amount int(11) NOT NULL COMMENT 金额单位分, channel varchar(16) NOT NULL COMMENT wechat / alipay / bank, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已退款, paid_at int(11) DEFAULT NULL COMMENT 支付时间戳, created_at int(11) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT业务订单表; CREATE TABLE payment_logs ( id int(11) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 关联订单号, channel varchar(16) NOT NULL, transaction_id varchar(64) DEFAULT NULL COMMENT 渠道交易号, raw_data text COMMENT 回调原始报文原样记录, result varchar(8) DEFAULT NULL COMMENT success / fail, created_at int(11) DEFAULT NULL, PRIMARY KEY (id), KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付回调流水表;流水表和订单表分离的原因是一笔订单可能收到多次回调、多次退款尝试如果都写进订单表会造成字段爆炸而且原始报文是排查问题的第一手证据。对账时把两张表按order_no关联查出“渠道侧有支付记录但本地订单未更新”的异常单就是一个完整的对账脚本。回调日志我习惯直接用 TP5 的Log::record记录渠道、订单号、原始报文、验签结果、处理结果一行 JSON排查问题时grep order_no就能把整条链路拉出来。4.3 从沙箱到真实支付的联调顺序联调顺序我建议严格按“支付宝沙箱 → 微信小额真实支付 → 回调幂等测试”三步走每一步都有对应的验证手段。第一步在支付宝开放平台创建沙箱应用把gateway切到沙箱地址用沙箱买家账号完成一笔真实的下单流程验证签名、跳转、异步回调三条链路。沙箱的异步通知是真实的能直接看到notify_url是否收到 POST。第二步微信支付没有官方沙箱我的做法是配一个最低价商品真实支付一块钱支付成功后立刻走退款流程把资金回退既验证了下单也验证了证书配置。这一步要提前确认notify_url的域名已经通过了微信支付侧的校验否则接口返回成功但回调根本打不进来。本地调试时用内网穿透把回调地址映射到开发机微信侧看到的是合法域名请求能真正打到本地代码里。第三步模拟重复回调。用 curl 往本地回调地址发一个之前记录下来的真实回调报文第一次应该返回success第二次同样返回success但业务表只更新一次流水表多一条记录。这个测试能直接暴露幂等逻辑是否生效curl -X POST https://pay.example.com/index/payment/notify/type/wechat \ -H Content-Type: application/xml \ -d xmlout_trade_no20250101001/out_trade_no.../xml这个假回调用没有正确签名的报文会被验签拦截返回 FAIL属于正常现象要测真正链路得从日志里把渠道原始回调原文抠出来原样重放。重放时观察数据库状态和流水表就能确认幂等和验签都在真实工作。5. 避坑笔记回调、证书、金额单位三座山的翻车复盘5.1 回调类问题地址校验、日志黑匣子与重复通知现象一微信下单返回成功但异步通知一直没有到达后台订单停留在待支付。原因notify_url里配的是http://或者是 IP 地址微信支付侧对回调地址有严格校验要求必须是 HTTPS 且域名与商户平台配置一致。解决检查配置里的notify_url是否使用备案过的 HTTPS 域名本地联调时用内网穿透映射到开发机穿透工具的临时域名通常能通过校验。现象二线上某笔订单渠道侧显示已支付本地订单状态没变翻日志发现回调入口完全没记录。原因回调入口没有第一步就写日志代码在解析或验签阶段直接抛异常PHP 又没开display_errors异常全被吞了。解决notify()方法第一行先记录原始报文再把整个处理包进try-catchcatch里记录异常栈并返回FAIL。那条日志就是后续排查的入口没有它等于让渠道方黑匣子。现象三同一个订单渠道方通知了三次库存被扣了三次。原因回调处理没有幂等线程 A 先查订单是待支付线程 B 同时查到也是待支付两边都执行了更新。解决用lock(true)配合事务先锁行再查状态已支付订单直接返回成功。注意锁一定要加在事务内部TP5 里Db::startTrans()之后再用lock(true)否则锁的边界不对。5.2 密钥、证书与金额单位三处最隐蔽的翻车点现象四页面显示订单 1 元微信回调里的total_fee是 100支付宝回调里的total_amount是 1.00订单状态判断直接失败。原因金额单位没有在驱动入口统一微信要分、支付宝要元业务层混用。解决业务层全部用“分”和整数运算微信驱动直接用$order[amount]支付宝驱动number_format($order[amount] / 100, 2)不让单位换算散落在业务代码里。现象五支付宝验签一直返回 FAIL。原因配置里把“应用私钥”和“支付宝公钥”填反了或者密钥字符串从后台复制下来时带了换行符和-----BEGIN头尾被存进数据表。解决在config/pay.php里载入密钥文件而不是粘贴字符串如果必须放字符串PHP 读进来后先str_replace(\n, )再包回 PEM 头尾。确认sign_type是 RSA2RSA 本身是 SHA1 签名接口会明确拒绝。现象六微信退款报“证书序列号不存在”。原因商户平台重新下载了证书但服务器上的apiclient_cert.pem和apiclient_key.pem没有同步更新或者配置路径指向了旧文件。解决证书下载后第一时间同步所有服务器在配置里用ROOT_PATH拼绝对路径部署时写一个校验脚本检查证书有效期退款接口调用后把响应里return_code和result_code都写日志不要只记成功。顺便说一句证书文件不要放 web 可访问目录cert/目录要加访问限制或放 webroot 之外。6. 二次开发三天扩展一个新支付驱动的标准化动作6.1 新的支付渠道如何接入这套驱动最大的价值在于接入成本恒定。新增一个渠道不是从零写一套支付逻辑而是复制driver/Bank.php这样一个模板实现四个方法pay、refund、notify、query。一个新渠道的骨架代码不超过 80 行核心是参数映射和签名算法?php namespace pay\driver; use pay\Driver; class Other extends Driver { public function pay(array $order) { // 1. 把 $order 里的 order_no / amount / subject 映射成渠道参数 // 2. 按渠道规则签名 // 3. 返回 [pay_url 跳转地址或二维码内容, type other] } public function notify() { // 1. 记录原始报文 // 2. 验签 // 3. 归一化成 [out_trade_no ..., amount ...] // 4. 调公共的订单更新逻辑 } public function refund(array $order) { // 按渠道文档组请求参数并签名 } public function query(array $order) { // 主动查询渠道订单状态用于对账 } }接入新渠道时三处改动就够了config/pay.php增加一个渠道配置块extend/pay/Driver.php不用动收银台模板里如果新渠道需要不同的展示形式比如银联需要表单自动提交就在收银台判断type other时输出一个自动提交的 HTML 表单。业务层其他代码零改动。网银支付这一类渠道虽然同步回调和异步回调的参数名和微信完全不一样但驱动隔离了这些差异订单模型永远只面对统一的out_trade_no和amount。6.2 一个对账验证的收尾习惯扩展完驱动之后我做联调时会先画一张参数映射表业务字段对应渠道字段、金额单位、签名方式、回调字段。表画完代码基本已经完成了一半。再准备一份模拟回调报文按渠道文档构造签名用 curl 重放一遍观察流水表和订单表的更新结果。真正上线前强制走一遍query()对账支付和退款各测一次确认渠道状态和本地状态能对上。从那以后我每次接支付渠道都强制先画三张清单参数映射、回调校验、日志字段写完才动手写驱动代码。这套流程救过我很多次尤其是几个月后要维护一个半年没碰过的支付项目时资料比记忆可靠得多。希望帮到你。本文还有配套的精品资源点击获取