简介:基于ThinkPHP框架的四方支付源码,是一款面向企业级开发者的商业版第四方支付聚合系统,主要用于解决多支付渠道接入繁琐、订单与资金管理分散等核心问题。源码内置预配置支付接口,涵盖商户管理、订单处理、资金结算、风险控制、API文档及多语言支持等模块,开发者可结合实际业务场景进行定制化二次开发,快速搭建稳定可扩展的支付服务。资源包约47.73MB,共2000个文件,其中gif与png等图片资源占比最高,php为核心业务处理代码,js、html、css构成前端界面与交互,dat、log等承担数据缓存与运行日志,sql、config等辅助数据库安装与系统配置,整体目录完整,便于按模块检索与部署。目前已有2656人学习下载,适合具备ThinkPHP基础、希望深入理解第四方支付架构或需快速交付聚合支付方案的PHP开发者参考使用。
1. 四方支付源码:为什么支付对接必须走一条统一网关
四方支付源码这个词,搜进来的人多半已经被“每家支付机构一套接口、一套密钥、一套文档”折磨过。先给结论:四方支付本质上是一个聚合网关,把扫码、钱包、银行卡等通道的支付能力收拢成一套统一的 API,商户站点只管对接这一个收银台,不用在乎背后是哪个三方渠道。这套代码基于 ThinkPHP 框架实现,包含商户下单、异步回调验签、订单查询、渠道配置、对账补单等完整链路。你拿到的是一套可以直接部署的后端程序,我从“理清架构 → 配置跑通 → 联调回执 → 排坑优化”这条路把它讲透。适合自己搭收银台的开发者,也适合接手类似四方支付项目后需要快速理清边界的技术人员。看懂这篇,至少能少走一半弯路。
2. 下单链路与数据表:跑通四方支付前先理清三件事
拿到四方支付源码,第一件事不是改数据库密码,而是先理清谁在调用谁。我拆过好几套类似的支付项目,后面出问题的几乎都是因为刚开始没把角色和链路搞清楚——回调地址填错、订单状态被覆盖、通道切换崩了,根源都在这。先把这三个问题想明白,再去看代码,你会发现自己已经能猜到每个文件是干什么的。
2.1 三个参与方与一次完整的下单请求
四方的“四方”不是指四个服务,而是商户站、四方平台、三方渠道、用户这四个参与方。商户站负责发起支付,四方平台负责聚合与转发,三方渠道负责实际扣款。理解这一层后,再看请求链路就顺了:
- 商户站向四方平台提交下单请求,带上商户编号 merchant_no、商户订单号、金额和期望使用的通道。
- 四方平台先校验商户签名,签名不过直接拒绝,通过则创建本地支付单,状态为“待支付”。
- 四方平台按渠道配置路由到对应的渠道处理类,把通用参数翻译成三方渠道要求的报文格式,并发起下单。
- 三方渠道返回支付链接或二维码,四方平台把结果回传给商户站,商户站展示给用户。
- 用户在三方渠道完成支付,三方渠道向四方平台预先配置好的 notify_url 推送异步通知。
- 四方平台验签、更新订单状态,再向商户站自己的 callback_url 推送一次通知,商户站确认后完成业务。
注意第 5 步和第 6 步是两个不同层级的回调。三方渠道不会直接通知商户站,只会告诉四方平台;四方平台验签后,再通过自己的回调机制通知商户。所以这套源码里回调相关代码是最值得细读的部分,也是最容易出现状态对不上的地方。
2.2 数据表设计:订单主表、通道配置表、回调流水表
有了链路再看数据表就不会晕。这套源码里核心是三个表:支付订单主表 pay_order、通道配置表 pay_channel、异步回调流水表 pay_notify_log。其余像商户表、结算表都属于外围业务,先把这三个理解透,就能看懂主流程。
CREATE TABLE `pay_order` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '四方系统内部订单号', `merchant_no` varchar(32) NOT NULL COMMENT '商户编号', `merchant_order_no` varchar(64) NOT NULL COMMENT '商户侧订单号', `channel_code` varchar(32) NOT NULL COMMENT '渠道编码,如 chan_scan、chan_bank', `amount` bigint(20) NOT NULL COMMENT '支付金额,单位:分', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付 1已支付 2已关闭', `notify_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '商户回调状态:0未通知 1已通知 2通知失败', `callback_url` varchar(255) NOT NULL COMMENT '商户回调地址', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_merchant_no` (`merchant_no`,`merchant_order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单主表';CREATE TABLE `pay_channel` ( `id` int(11) NOT NULL AUTO_INCREMENT, `channel_code` varchar(32) NOT NULL COMMENT '渠道唯一编码', `channel_name` varchar(64) NOT NULL COMMENT '渠道展示名称', `handler_class` varchar(128) NOT NULL COMMENT '渠道处理类,指向具体的支付逻辑', `config` text COMMENT '渠道密钥配置,JSON存储', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1启用 0停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_channel_code` (`channel_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付通道配置表';订单表里两个字段的设计要特别留意。amount 用 bigint 存“分”,不是 decimal,也不是 float。四方支付网关一般统一用整数分做存储和传参,这能直接避开后面所有浮点比较的问题。status 用 0/1/2 三个值,后续所有幂等处理全靠这个字段的条件更新来实现,后面第 4 章会展开讲。
回调流水表是很多人容易漏掉的表。三方渠道异步通知到达后,不要只想着“改订单状态”,先把原始报文落库。我习惯加一张 pay_notify_log,把验签结果、处理结果、原始报文都记下来:
CREATE TABLE `pay_notify_log` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '四方系统订单号', `channel_code` varchar(32) NOT NULL COMMENT '渠道编码', `notify_data` text COMMENT '三方回调原始报文', `sign` varchar(64) DEFAULT NULL COMMENT '原始签名', `verify_result` tinyint(4) NOT NULL DEFAULT '0' COMMENT '验签结果 0未验 1成功 2失败', `process_result` tinyint(4) NOT NULL DEFAULT '0' COMMENT '处理结果 0未处理 1已入账 2重复通知 3金额不符', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='三方异步回调流水表';这张表是排查问题的黑匣子日志。线上如果出现用户说“付了钱但没到账”,先查这张表里有没有回调记录,有记录就看 verify_result 和 process_result,比靠猜强得多。
2.3 下单接口的实现:参数校验与收银台拼接
下单接口是整套四方支付的入口。常见的入口路径是/api/order/create,接收商户号、商户订单号、金额、渠道编码和签名。代码结构一般类似这样:
public function create() { $merchantNo = $this->request->param('merchant_no'); $merchantOrderNo = $this->request->param('merchant_order_no'); $amount = $this->request->param('amount'); $channelCode = $this->request->param('channel_code', ''); // 1. 校验参数与商户签名 // 2. 生成四方内部订单号 pay_order_no // 3. 写入 pay_order,状态为 0,amount 转成分 // 4. 按 channel_code 查 pay_channel 表,实例化渠道处理类 // 5. 调用渠道处理类的 createOrder() 获取支付链接或二维码 // 6. 把 pay_url / qr_code 返回给商户站 }代码逻辑不复杂,但四个点必须卡死。第一,金额单位在入口就要统一成“分”,后面所有逻辑不用再关心元分转换。第二,merchant_order_no 要加唯一索引,否则商户重复提交同一个订单号会插入两条订单,对账时会疯掉。第三,渠道处理类要按 pay_channel 表的 handler_class 动态实例化,而不是在控制器里写死 if-else,不然每接一个渠道就要改一次控制器。第四,商户签名校验必须放在创建订单之前,校不过直接返回错误。
返回给商户站的数据结构通常是这样的:
{ "code": 0, "msg": "ok", "data": { "order_no": "PAY202501011200001", "pay_url": "https://pay.provider.example/checkout?token=xxx", "qr_code": "https://pay.provider.example/qr/yyy.png" } }商户站拿到这个结果后,前端判断有 pay_url 就跳转,有 qr_code 就渲染二维码。这里有个容易被忽略的点:返回结构里的字段名不要随意改,商户已经按你的接口文档对接好了,改字段名对后端来说只是改一行代码,对商户来说可能要重新发版。
3. 部署与联调:ThinkPHP 四方支付从环境准备到第一笔回执
链接路和表设计看完,接下来就是把源码真正跑起来。这个阶段常见的状态是:目录权限给了、数据库导入了、页面还是 500;或者配置里回调地址填错了,下单能成功,回执永远收不到。这一章按部署顺序走,每一步都给出对应检查点。
3.1 运行环境准备:PHP 扩展与 ThinkPHP 伪静态
这套四方支付源码基于 ThinkPHP,按 ThinkPHP 5.x 的习惯,入口在public/index.php。部署环境要求很常规:PHP 7.1 以上,必须启用 curl、openssl、fileinfo、mbstring、pdo_mysql。前两个没开,支付请求根本发不出去;fileinfo 没开,Composer 安装依赖时会直接失败。
如果资源包里带了 vendor 目录,可以跳过 Composer 安装,直接用;没有 vendor 的话,在项目根目录执行:
composer install --no-dev --optimize-autoloader然后配置伪静态。ThinkPHP 默认走 pathinfo 模式,NGINX 下最常见的配置是这样:
server { listen 80; server_name pay.example.com; root /data/www/pay/public; index index.php index.html; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; break; } } location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }这里最坑的是 root 指向。很多同学把 root 指到项目根目录,然后访问http://域名/index.php/index/order/create看着像能通,但静态资源和 URL 生成全乱。正确姿势是 root 指向public目录,框架外的文件不让 Web 服务器直接访问。Apache 环境则要开启 mod_rewrite 并允许.htaccess覆盖,否则路由不解析。
3.2 应用配置:数据库、支付通道与回调地址的对应关系
数据库导入完成后,第一步改.env里的数据库连接,第二步看支付配置。这套源码的支付配置通常在config/pay.php,结构是每个渠道一个配置组:
return [ 'merchant_default' => 'M10001', 'channels' => [ 'chan_scan' => [ 'handler' => 'app\\pay\\channel\\ChanScanChannel', 'app_id' => 'ca_20250101', 'mch_id' => '890123456', 'api_key' => 'f3a1b2c3d4e5...', 'notify_url' => 'https://pay.example.com/index.php/api/notify/chan_scan', ], 'chan_bank' => [ 'handler' => 'app\\pay\\channel\\ChanBankChannel', 'app_id' => 'bank_gw_001', 'mch_id' => '20250001', 'private_key' => '-----BEGIN RSA PRIVATE KEY-----...', 'notify_url' => 'https://pay.example.com/index.php/api/notify/chan_bank', ], ], ];每个字段的作用如下表所示,新增渠道时照着这套格式复制一份改参数即可。
| 配置项 | 含义 | 注意事项 |
|---|---|---|
| handler | 渠道处理类完整路径 | 必须与项目里实际类文件路径一致,否则下单直接报“类不存在” |
| app_id | 渠道侧分配的应用标识 | 每个渠道一个,不能混用 |
| mch_id | 渠道侧商户号 | 与 app_id 配套,渠道侧注册时生成 |
| api_key / private_key | 请求签名/验签密钥 | 服务端持有,绝不允许出现在前端 JavaScript 里 |
| notify_url | 四方平台接收渠道异步通知的地址 | 建议 HTTPS,确保外网可达;每渠道各配一个 |
配置参数里最容易翻车的是 handler 类路径。ThinkPHP 的命名空间和目录路径强绑定,app\pay\channel\ChanScanChannel对应application/pay/channel/ChanScanChannel.php,少一个字母都加载不出来。配完后可以先手动new一下这个类,能实例化再继续后面的联调。
3.3 首笔支付联调:从下单到异步回调的最小闭环
环境通了之后,先不着急接真实渠道,用四方平台自带的测试通道走一笔最小闭环。先模拟商户站发起一次下单请求:
curl -X POST https://pay.example.com/index.php/api/order/create \ -d 'merchant_no=M10001&merchant_order_no=TEST20250101001&amount=100&channel_code=chan_scan' \ -d 'sign=3a9f0c2b8d1e4a7f6c0b9d2e5f1a8c4d'sign 的生成规则见资源包内的接口文档,一般是把参与签名的参数排序后拼接商户密钥再做 MD5。返回的 JSON 里有 pay_url 或 qr_code 后,在浏览器打开,用测试钱包完成一笔 1 元支付。
支付完成后五分钟内不要动数据库,先去查 runtime 日志。ThinkPHP 的日志默认写在runtime/log/年月日.log,里面能看到三方渠道回调四方平台的原始 POST 报文。确认拿到回调后,再看 pay_order 表的状态有没有从 0 变成 1。如果回调到了但状态还是 0,说明验签或业务处理挂了,去 pay_notify_log 看 verify_result 字段,这比盲目改代码高效得多。
4. 避坑指南:四方支付源码里最容易翻车的四个位置
四方支付源码本身不复杂,但线上出问题的时候你会觉得它像个黑匣子——数据对不上、账不平、回调莫名丢失。这一章把我在类似项目里踩过的坑集中写出来,每条按“现象 → 原因 → 解决”拆开。如果你正被某个诡异问题卡住,先对照这一章找找。
4.1 回调验签失败:签名顺序是四方支付最常见的“玄学”
现象:用户已经完成扣款,四方平台的订单却一直停在“待支付”状态,pay_notify_log 里 verify_result 为 2。
原因:回调验签时参与签名的参数集合和渠道约定不一致。最常见的有三种:一是把值为空的参数也拼进去了;二是参数排序方式不对,没有按 ASCII 升序排序;三是字符串编码不一致,UTF-8 和无 BOM 混用导致最终拼接结果不同。四方支付源码里每个渠道有自己的签名规则,不能因为两个渠道文档长得像就用同一套逻辑。
解决:把验签写成独立方法,严格按“剔除空值 → ksort 排序 → http_build_query → 拼接密钥 → 摘要”的顺序执行:
public function verifySign(array $params, string $sign, string $apiKey): bool { // 1. 剔除签名字段本身 unset($params['sign'], $params['sign_type']); // 2. 剔除值为空的参数 $params = array_filter($params, function ($value) { return $value !== '' && $value !== null; }); // 3. 按键名 ASCII 升序排序 ksort($params); // 4. 拼接 query string 后追加密钥 $signStr = urldecode(http_build_query($params)) . '&key=' . $apiKey; // 5. 计算摘要并比对,比较前统一转大写 return strtoupper(md5($signStr)) === strtoupper($sign); }这段要注意 php 版本语法,fn()短函数在 PHP 7.4+ 才支持,生产环境如果还在 7.1,换成普通的匿名函数更稳妥。排查验签失败时,把接收到的原始 POST 参数原样打到日志里,和渠道文档里的示例逐字对比,别只看“验签失败”四个字。
4.2 金额比对失败:分与元的精度陷阱
现象:用户支付金额和订单表里的金额明明一样,回调却报“金额不符”。
原因:几乎都是单位没换算。三方渠道回调报的金额是元,订单表存的是分,直接拿 100.00 和 10000 比较,怎么都不相等。还有一部分情况是 PHP 浮点数比较的坑,0.1 + 0.2 == 0.3在 PHP 里也会得到 false。
解决:回调金额先转成分,然后把两边的值都强制转成 string 再比较:
$callbackAmount = (int) round((float) $params['amount'] * 100); $orderAmount = (int) Db::name('pay_order') ->where('order_no', $orderNo) ->value('amount'); if ((string) $callbackAmount !== (string) $orderAmount) { // 写入日志并返回非 success,让渠道稍后重推 }强转成字符串这一步至关重要,它规避了 PHP 数据类型自动转换带来的隐式比较问题。另外注意比较失败时不要直接把订单标记为支付失败,正确做法是记录日志后返回给渠道一个非成功应答,让三方渠道稍后重试通知。
4.3 回调重复通知:没有幂等处理等于白做状态机
现象:昨天已经支付成功的订单,今天又收到一次三方回调,商户侧财务账里多了一笔收入。
原因:三方渠道为了保证通知可靠送达,在第一次回调没有得到明确成功回执时,会按退避策略隔几分钟、几小时甚至第二天再推一次。开发者如果对同一订单做无条件的UPDATE pay_order SET status = 1,第二次通知会把状态再刷一遍,还可能触发商户侧重复入账脚本。
解决:两层防线。第一层是回调流水表按“订单号 + 渠道编码 + 渠道通知 ID”加唯一索引,重复报文直接不进业务处理;第二层是更新订单时加条件,只有待支付状态才能改为已支付:
$updated = Db::name('pay_order') ->where('order_no', $orderNo) ->where('status', 0) ->update([ 'status' => 1, 'paid_time' => date('Y-m-d H:i:s'), 'notify_status' => 1, ]); if (!$updated) { // 影响行数为 0,说明订单已被处理过,直接应答成功 return 'success'; }加了where('status', 0)之后,重复通知的 update 影响行数为 0,就不会重复入账。还有一种情况是业务侧已经把订单关闭了,此时 status 是 2,status = 0条件同样会拦住更新,避免出现“关了单又被回调改回去”的尴尬。
4.4 回调超时与通知丢失:异步通知不保证必达
现象:用户在渠道侧完成了支付,但四方平台既没有收到异步通知,也没有主动变更订单状态,商户来问“钱去哪了”。
原因:三方回调请求在四方平台 Nginx 层因为超时被切断,或者回调脚本里同步调用了商户站点的回调接口,商户站点响应慢,把三方回调链路卡死,最终触发渠道超时重推。还有一种是四方平台在下单时就把回调地址填成了内网 IP,外网的三方渠道根本打不进来。
解决:回调地址必须保证外网可访问,且回调处理逻辑要“快进快出”。四方平台收到三方回调后,先落库、再验签、再改状态,最后把通知商户的动作放到异步队列或者独立脚本里执行,绝不能在三方回调的请求链路里同步等待商户站点的响应。针对通知丢失的情况,要写一个对账补单脚本,每分钟扫描超过 5 分钟仍处于待支付状态的订单,主动去渠道查询真实支付状态,查到已支付就强制补单并通知商户。补单脚本的思路很简单,但它是整个四方支付系统最后一层“后悔药”。
5. 进阶验证:把回调处理收敛成幂等状态机
这套四方支付源码里,我认为最值得做的一次升级是把回调处理从“收到通知就改状态”改成“受约束的状态迁移”。换句话说,支付订单的状态不是任意跳的,而是只有三种允许的迁移路径:待支付 0 → 已支付 1,待支付 0 → 已关闭 2,已支付 1 是终态,不允许回退或覆盖。这个约束写死在业务代码里,靠的就是条件更新 SQL。
$result = Db::execute( "UPDATE pay_order SET status = 1, paid_time = ? WHERE order_no = ? AND status = 0", [$now, $orderNo] ); if ($result > 0) { // 状态从 0 迁移到 1,触发商户通知与入账逻辑 } else { // 当前状态不是 0,可能是终态,也可能是重复通知,直接应答 success }这个约束的价值在于:无论三方渠道推多少次通知,无论对账脚本和异步回调谁先谁后,订单状态迁移都是原子的。先到先得,后到不覆盖。对账补单脚本扫描到“用户已支付但订单仍待支付”的情况时,也是执行这一段代码,和异步回调共用同一条状态迁移逻辑,不会出现两个模块各写一套 update 导致互相覆盖的情况。
回调处理完整顺序我建议固定为五步:验签 → 查状态 → 条件更新 → 落流水 → 异步通知商户。每一步都可追溯,pay_notify_log 表里应该能看到 verify_result 和 process_result 两个维度的结果,出现任何问题都能靠订单号把三方回调、四方订单、商户通知三段链路串起来。
之前有一次渠道超时故障,我图省事,在回调里直接“先改状态、再补日志”,结果商户回调地址超时导致通知失败,订单状态却已经是已支付。第二天三方渠道补推通知,又因为没做幂等把同一笔订单处理了两次,财务对账对到凌晨。从那以后我每次写四方支付源码里的回调处理,都强制走一遍“验签 → 查状态 → 条件更新 → 落流水 → 异步通知”的顺序,宁可多写几张表,也不能把状态更新操作写得像一把梭。这个习惯帮我少挨了很多次“账不对”的骂,希望帮到你。
本文还有配套的精品资源,点击获取