简介:面向PHP开发者的四方支付商业源码,基于ThinkPHP框架构建,属于企业级第四方支付API系统。它整合支付宝、微信支付、银联等多家第三方渠道,为商家提供统一支付入口,并完整覆盖商户管理、订单处理、资金结算、风险控制等业务环节,适合需要快速搭建或定制支付平台的开发者。资源包共2000个文件,压缩后大小47.73MB;其中849个PHP文件构成核心业务模块,1001个gif、477个png、182个jpg等图像素材负责界面与图标,js/html/css文件用于前端交互,dat/config/sql等承担配置与数据存储,log日志、readme/license文档辅助调试与授权核对,整体目录结构清晰,便于按模块检索和二次开发。该资源已有2661人学习使用。商业版源码附带完整的支付接口文件、API文档与风险控制机制,可直接对接常用支付渠道,同时包含后台管理、接口回调、商户入驻等可运行代码,开发者既能实际部署,也能参考其模块划分与类封装方式,大幅降低从零搭建支付系统的时间成本。
1. 四方支付源码:ThinkPHP 聚合支付到底在解什么题
做支付系统开发的都知道,真正折磨人的不是那几家主流渠道的官方接口,而是商户手里那十几张不同渠道的合同,每张合同费率不一样、结算周期不一样、回调字段还不一样。四方支付系统干的事,就是在这些渠道之上做一层聚合,让商户只对接一个接口,剩下的路由、对账、代付全由系统消化。这套基于 ThinkPHP 的四方支付源码,解决的正是「一套代码管住所有渠道」的问题,适合已经有 PHP 基础、想快速搭建聚合支付平台的开发者,或者拿来二次改造做内部支付网关的团队。代码量不夸张,但架构上把商户、代理、渠道、代付拆得很干净,值得完整跑一遍再动手改。
2. 部署这套 ThinkPHP 四方支付系统:环境选型、目录结构与十分钟起步
拿到这套四方支付源码的第一件事,不是急着看业务代码,而是先把运行环境对齐。ThinkPHP 的版本和 PHP 版本之间有明确的兼容边界,装错了后面全是莫名其妙的白屏和报错,这一章把环境选型和部署参数一次说清。
2.1 环境选型:为什么锁定 ThinkPHP 5.1 而不是 6.x 或 Laravel
这套源码基于 ThinkPHP 5.1 开发,核心原因在于 5.1 的生命周期和支付场景非常契合。ThinkPHP 5.1 的中间件机制比 5.0 完善,但又没有 6.x 那种强制依赖 PHP 7.2+ 的约束,实际部署时在 PHP 7.1 到 7.4 之间都能稳定跑,这在很多老服务器上非常关键。如果你用的是 PHP 8.0 以上环境,ThinkPHP 5.1 的底层代码会有一批each、list等语法兼容问题,需要手动改才能跑起来,所以建议直接选 PHP 7.4 搭配 Nginx 1.18 以上。
数据库方面,这套源码依赖 MySQL 5.7+,需要注意 5.7 的ONLY_FULL_GROUP_BY模式对 ThinkPHP 查询构造器生成的 SQL 会有一定影响。我一般会在 MySQL 配置文件里显式关闭这个模式,或者给需要的字段加GROUP BY处理,后续跑对账报表时会少很多坑。Redis 在这套系统里不是必选的,但如果要跑渠道轮询和并发订单生成,建议还是把 Redis 装上,后面第 3 章会讲到具体用法。
Web 服务器方面,请不要用 Apache 直接跑这个项目。源码里大量使用了 ThinkPHP 的路由重写功能,Apache 的.htaccess配置和 Nginx 的try_files行为有差异,会导致访问任意非根目录路由时全部 404。统一用 Nginx 部署,配置可以直接照抄下面的章节。
2.2 落地部署:Nginx 配置、数据库导入与三处必改参数
部署的第一步是创建 Nginx 站点配置,这里把server块完整贴出来,里面每一项都有实际作用,不是模板凑数:
server { listen 80; server_name pay.zhifu.test; root /data/www/quadpay/public; index index.php index.html; location / { try_files $uri $uri/ /index.php?s=$uri$args; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_param PHP_VALUE "upload_max_filesize=20M post_max_size=20M"; } location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ { expires 7d; access_log off; } }这份配置里最关键的是try_files那一行,它把所有非文件请求转发到index.php,这是 ThinkPHP 5.1 路由能生效的前提。如果你的项目装在子目录,还需要把root改成实际路径,同时确认public目录是站点根目录,否则 ThinkPHP 会把public当成 URL 的一部分,所有资源路径全错。fastcgi_pass的端口要和 PHP-FPM 实际监听端口一致,常见的有9000和9001,具体看你服务器的 PHP 安装方式。
第二步是导入数据库。源码包里通常会有一份sql文件,可能是quadpay.sql或者install.sql,直接在 MySQL 里执行:
mysql -uroot -p --default-character-set=utf8mb4 quadpay < quadpay.sql导入完成后,修改application/database.php里的数据库连接参数。这里要特别注意,ThinkPHP 5.1 的数据库配置里有个prefix字段,默认一般是pay_,实际看你的 SQL 文件里表名前缀是什么就填什么。很多新人在这里翻车:SQL 表名是pay_user,配置里写tp_,结果一登录就报「表不存在」。
第三处必改参数在.env文件里。这套源码支持.env覆盖config.php,需要把app_debug关掉并设置app_trace为 false。生产环境开着调试模式,相当于把 SQL 语句和内部变量全部暴露给前端,这是支付系统最不能接受的。我一般还会顺手把default_module改成index,防止有人直接通过入口文件猜后台路径。
2.3 初跑冒烟:从商户后台到下单链路的最小闭环
部署完成后,第一步不是登录后台,而是先确认入口文件能正常访问。直接访问http://你的域名/,看到登录页说明框架没跑起来的问题。接着用源码包里自带的初始账号登录商户后台,创建一个测试商户和对应的支付渠道。
这里的冒烟测试重点是「下单 → 回调」这条完整链路。常见做法是先在商户后台生成一个支付订单,拿到支付链接,然后手动模拟回调。手动模拟回调时,需要按文档里约定的签名算法生成 sign 参数,直接用浏览器访问回调地址。如果签名校验通过,订单状态会从「待支付」变成「已支付」,这一步通了,说明系统的核心状态机没毛病。
我一般会在这一步顺手检查runtime/log目录下的日志文件。ThinkPHP 5.1 的日志默认写在runtime/log/年月日文件里,能看到订单创建时的完整参数和 SQL 语句。这一步虽然不起眼,但对后面排查回调问题帮助极大,别等出了问题才想起来开日志。
3. 核心代码拆解:商户管理、渠道路由与代付结算怎么分工
这套四方支付源码的业务模块大致可以分成三个部分:商户管理、渠道路由、代付结算。三者之间不是简单的文件划分,而是通过数据库表的状态字段互相咬合。搞清楚这三个模块怎么分工,基本上就掌握了这套系统的骨架。
3.1 商户与代理:两套后台体系背后的表结构设计
四方支付平台通常有商户端和代理端,有些还拆出运营后台。这套源码在表设计上把merchant和agent拆成两张独立表,而不是用角色字段区分,这个设计有两个好处:一是后续给代理加佣金比例时不会污染商户表;二是查询隔离更干净,不用到处写where type = 2。
商户表里比较关键的字段是api_key、callback_url和status。api_key是商户调用下单接口时的身份凭证,源码里用md5做签名用的,后面第 4 章会展开讲。callback_url是商户希望接收支付结果通知的地址,这个字段的校验逻辑值得看一下——源码会检查域名格式和后缀,避免有人把回调地址指向内网 IP。status字段控制商户能否下单,冻结状态下的商户发起下单会直接被拦截。
代理表的核心是「费率差」逻辑。代理名下挂多个商户,每个商户的费率可以单独设置,但源码里有个保护机制:商户费率不能低于代理费率的上限。这个机制在application/api/controller/Agent.php里实现,创建商户时会把代理的fee_rate作为默认值,并且在前端表单里做了min属性限制。如果你要二次开发,注意这个约束是前端校验还是后端校验,我看了代码,后端也做了判断,这点做得还算到位。
3.2 渠道路由:余额、费率、权重三者联动的选路算法
渠道路由是四方支付系统的技术核心。商户发起一笔下单请求时,系统需要在多个上游渠道里挑一个去承接这笔订单。源码里的选路算法综合考虑了三个维度的参数:渠道余额、渠道费率、渠道权重。
渠道余额在channel表里用balance字段表示,余额低于设定阈值的渠道会被剔除出候选池。这个阈值不是固定的,在application/admin/controller/Channel.php里可以通过后台配置。渠道费率决定这笔订单的成本,系统倾向于选择费率低的渠道,但不会死板地只看费率。渠道权重是个整数字段,值越大被选中的概率越高。源码的实现在application/api/service/RouteService.php,逻辑大致如下:
public function selectChannel($amount, $payType) { $channels = Db::name('channel') ->where('status', 1) ->where('pay_type', $payType) ->where('balance', '>', 100) ->order('fee_rate asc') ->select(); if (empty($channels)) { return null; } $totalWeight = 0; foreach ($channels as $ch) { $totalWeight += $ch['weight']; } $rand = mt_rand(1, $totalWeight); $cursor = 0; foreach ($channels as $ch) { $cursor += $ch['weight']; if ($rand <= $cursor) { return $ch; } } }这段代码的意思是:先按费率从低到高排序,再用权重做随机抽取。权重越高的渠道在随机过程中占的区间越大,所以整体上费率低且权重高的渠道会优先被选中,但权重低的渠道也有机会分到订单。这样做的好处是避免流量全砸在一个渠道上导致单渠道风控,但代价是订单的费率不是最优的。运营层面需要权衡:要最优费率就调高费率字段的权重逻辑,要稳定出款就保持现有的加权随机方案。
实际使用中,渠道余额的判定不能只依赖balance字段,因为上游渠道的实时余额可能和本地记录有延迟。常见的做法是把「本地余额小于 500 元」和「最近 10 分钟内连续失败 3 次」两个条件同时作为剔除标准。源码里没有内置连续失败次数的统计逻辑,建议二次开发时在order表里加一个last_fail_time字段,配合定时任务做自动恢复。
3.3 代付与结算:T0 到账背后的状态机
代付模块在源码里对应的是payment相关控制器和pay_order表。商户发起代付请求后,订单状态从「待处理」流转为「处理中」,然后根据上游渠道的回执变为「成功」或「失败」。这个流转过程涉及一个很重要的状态机设计——订单表里没有用单一状态字段硬扛,而是拆成status和sub_status两个字段,status控制主流程,sub_status记录失败原因、重试次数等信息。
代付的「成功」和「失败」不能只信上游的同步返回。同步返回成功只代表请求接收成功,不代表资金真的打过去了。经历过代付调单的人都知道,上游说处理中,结果几个小时没动静的场面太常见了。所以源码里在command目录下放了一个定时任务脚本,每隔一段时间扫描「处理中超过 5 分钟」的订单,主动向上游查单,根据查单结果更新订单状态。这个脚本的启动方式不是靠 ThinkPHP 自带的crontab命令,而是写好了 shell 脚本,在服务器上配系统定时任务调用。
4. 对接商户与上游:签名、回调与通道切换的完整姿势
四方支付系统的两头都是对接,一头对接商户,一头对接上游渠道。商户侧的核心是签名算法和回调通知,上游侧的核心是渠道切换和多供应商适配。这一章把两侧的完整对接姿势都梳理清楚,不留黑匣子。
4.1 商户侧接入:API 密钥、网关地址与签名计算
商户接入四方支付时,平台会分配一个app_id和一个api_key。商户下单时,需要把业务参数按规则拼接,然后用api_key做签名,平台收到请求后会重新计算签名做校验。这套源码的签名算法是典型的 MD5 签名,和大多数支付平台的规则类似:
public function makeSign($params, $apiKey) { ksort($params); $str = ''; foreach ($params as $k => $v) { if ($v === '' || $v === null) { continue; } $str .= $k . '=' . $v . '&'; } $str .= 'key=' . $apiKey; return strtoupper(md5($str)); }这段代码的逻辑分三步:第一步去掉空值参数;第二步对所有参数按 key 的字典序排序;第三步把排序后的键值对拼接成key=value&key=value的字符串,末尾追加上key=apiKey再做 MD5。strtoupper表示最终签名全部转大写,商户端对接时也是这样。
这里的几个细节值得注意。首先,空值过滤和排序缺一不可,漏掉任何一个都会导致签名不一致。其次,拼接用的连接符是&,但最后一个 kv 对后面不加&,直接在末尾接key=。很多商户初筛时总说签名不对,原因就是多加了或漏加了&。第三,签名时传进去的params是业务参数本身,里面不能包含sign字段本身,否则签名会循环依赖。
商户请求下单接口时,除了业务参数和sign,源码还要求带上timestamp参数,并且校验时间差不能超过 5 分钟。这个设计能防止重放攻击,但在实际对接中也会造成困扰——商户服务器的时间和平台服务器时间偏差超过 5 分钟,就会被拒单。我一般会把时间差放宽到 10 分钟,再配合 Redis 做订单号幂等,防重放的效果是一样的。
4.2 异步回调链路:状态更新与幂等处理的顺序
支付回调是所有支付系统最核心的环节,顺序错了就会造成资金损失。这套源码的回调处理流程,我拆开看了一遍,顺序是「验签 → 查单 → 更新订单 → 通知商户」,这个顺序是对的,但有几个边界问题容易被忽略。
回调地址是商户在后台配置的,平台需要把支付结果主动 POST 到商户的callback_url。这个通知动作在源码里用的是同步请求,也就是说平台在收到上游回调后,通过file_get_contents或curl直接请求商户的地址,拿到商户返回的success字符串才算通知完成。如果商户的接口超时或返回异常,平台会记录通知失败次数,并由定时任务做补偿。
订单状态的更新必须加幂等保护。同一个上游回调可能由于网络抖动被发送两次,如果第一次已经更新了订单状态,第二次再次处理时,order_status已经不是待支付了,那么第二次更新要么被拒绝,要么必须保持幂等。源码里在更新前会先查一次订单状态:
$order = Db::name('pay_order')->where('out_trade_no', $outTradeNo)->find(); if ($order['status'] == 1) { return json(['code' => 0, 'msg' => '订单已处理']); }注意这里有个并发隐患:如果两个回调请求同时到达,两个进程同时查到status = 0,然后同时走更新逻辑,就可能造成重复入账。解决办有两种:一种是在pay_order表上给out_trade_no加唯一索引,这样第二个更新请求会因为affected为 0 而失败;另一种是用UPDATE ... WHERE status = 0的方式做乐观锁更新。源码里没加唯一索引,建议二次开发时补上,这是典型的并发翻车点。
4.3 上游渠道切换:同接口多供应商的适配层写法
四方支付上游接的渠道五花八门,有的返回 JSON,有的返回 XML,回调字段命名也完全不一样。这套源码的处理办法是给每个渠道单独建一个驱动类,统一实现相同的接口方法。
打个比方,渠道 A 代付返回的字段叫trans_id,渠道 B 叫deal_no,如果业务代码直接读字段名,每接一个渠道就要改一遍业务逻辑。源码在每个渠道驱动类里定义了parseResponse方法,把不同字段统一转成内部的order_no、status、amount结构。新增渠道时只需要写一个继承基础驱动的新类,实现几个方法就行,业务层完全不用动。
这个适配层的通用写法大致是这样的:
abstract class BaseChannel { abstract public function pay($order); abstract public function withdraw($order); abstract public function parseNotify($rawData); }parseNotify是最容易出问题的部分。不同渠道返回的数据格式差异巨大,有的在 JSON 顶层放状态码,有的套了一层data;有的用success表示成功,有的用S。实战经验是写parseNotify时不要想把所有渠道统一成同一种数据结构,而是把所有可能出现的字段先拉一遍映射表。这套源码的初始化 SQL 里就有一张channel_field_mapping表,存的是不同渠道字段和内部字段的对应关系。
5. 四方支付系统避坑手册:五条记录
这套源码跑通不难,但跑稳不容易。下面这五条都是实际部署和二次开发中最常见的问题,每一条都按「现象 → 原因 → 解决」的结构写清楚,建议先收藏再对照排查。
5.1 回调不触发:端口、rewrite 与防火墙三连
现象:商户下单后,上游渠道显示支付成功,但平台订单状态一直停在「待支付」,手动模拟回调却能通。
原因:这套系统接上游回调依赖一个单独的notify接口,这个接口在 Nginx 配置里走的是 PHP-FPM。多数情况下问题出在 Nginx 的location规则上,部分渠道的回调地址带着路径参数,如果try_files写得不完整,请求会被 Nginx 直接拦截返回 404。另外,上游回调服务器可能走的是不同网段,服务器防火墙或安全组没放行对应端口,回调请求根本到不了 Nginx。
解决:先用tail -f /var/log/nginx/access.log观察上游回调是否打进来。如果日志里能看到请求但没进 PHP,说明是 rewrite 规则问题;如果日志里完全没有记录,说明是网络层拦截了。网络层检查firewalld或云安全组的入方向规则,把上游渠道的回调 IP 段加入白名单,或者临时全放行测试,确认后再收紧。
5.2 订单重复入账:并发下缺少唯一约束的连锁反应
现象:用压力测试工具同时发两笔相同订单号的请求,后台发现这笔订单被入账了两次。
原因:pay_order表上out_trade_no字段没有加唯一索引。两个请求在并发场景下都通过了「订单不存在」的检查,然后各自把状态更新成了成功,造成重复入账。
解决:给out_trade_no加上普通唯一索引,同时对外部传入的订单号做格式校验,限制长度和字符集。这样即使请求重复,第二次INSERT会因为唯一索引直接报错,不会造成脏数据。加索引的 SQL 很简单,但很多人做完这步就忘了处理订单查询的异常逻辑——插入失败后要抛出业务异常,而不是让框架直接白屏。
5.3 签名总失败:参数排序和空值过滤不一致
现象:商户对接联调时,请求始终返回「sign error」。商户端拿着平台给的示例代码和密钥算出来的签名,和平台算的完全不一样。
原因:最常见的原因是商户把api_key拼在参数串中间了,而不是拼在末尾。另一个常见原因是参数里有空值,商户按照文档把所有参数都算进签名,但平台在验签时先过滤掉了空值,两边的签名自然对不上。
解决:调 API 网关的日志,把商户上送的参数原样打印出来,然后用平台自己的验签逻辑跑一遍,看是否匹配。如果不匹配,让商户把他生成签名时的参数串发过来对比。这个工序看起来笨,但效率最高。如果商户拿到的签名示例代码是旧的,比对参数拼接格式是不是和源码里makeSign的ksort + 空值过滤逻辑一致。
5.4 上游渠道返回乱码:响应头编码与 JSON 解析的冲突
现象:代付提交到上游渠道后,返回的错误信息是一串乱码,渠道系统里却显示请求成功。
原因:上游渠道的响应头没有规范声明charset,返回的 JSON 是 UTF-8 编码,但 PHP 的curl默认按 ISO-8859-1 解析,导致中文全部变成–这类乱码。
解决:用curl_setopt强制设置CURLOPT_ENCODING为空字符串,或者直接在代码里对响应内容做mb_convert_encoding($res, 'UTF-8', 'auto')转换。更彻底的做法是在请求头里明确加上Accept: application/json; charset=utf-8,让上游渠道按 UTF-8 返回。注意这治标不治本,如果上游个别接口确实返回 GBK 编码,需要单独在渠道驱动类里配置编码格式,不能一刀切。
5.5 定时任务失效:Crontab 环境变量与 PHP 路径陷阱
现象:后台的代付自动查单定时任务配置好了,但迟迟没有任何效果,手工执行 shell 脚本没问题。
原因:Crontab 执行时使用的环境变量和手动登录 shell 时不一样,PATH里没有 PHP 的路径,脚本里用php命令找不到解释器。还有一种情况,脚本里用了相对路径,Crontab 执行时当前目录是/root而不是项目目录,文件相对路径全部失效。
解决:在 Crontab 里使用绝对路径写死 PHP 和脚本的路径,比如/usr/local/bin/php /data/www/quadpay/think crontab:withdraw。脚本内部也建议用dirname(__FILE__)做路径切换,避免依赖当前工作目录。加上 Crontab 的环境变量开头,确保基本指令可用。
6. 把上游回调验签做成统一收银台:适配器收敛所有差异,杜绝漏单
到了这一步,系统的各项功能基本都能跑了,但上游回调的处理仍然是整个环节里最脆弱的。接的渠道越多,回调差异越大,每加一个渠道就要改一遍回调处理逻辑,时间一长代码里全是分支判断。这里要分享一个我在这套源码上做的关键改造:把回调验签、字段映射、状态更新收敛成一个统一收银台,所有渠道的回调都走同一个入口,差异只在适配器里体现。
这个适配器的目标很明确:上游渠道回调到了之后,先做验签,验签通过后调用一个 channel 层方法解析原始数据,把不同字段统一成内部标准格式,最后再更新订单状态。改造后回调处理的代码结构如下:
public function unifiedNotify() { $channelCode = request()->param('channel'); $adapter = ChannelFactory::create($channelCode); if (!$adapter->verifySign()) { return 'sign_fail'; } $rawData = file_get_contents('php://input'); $parsed = $adapter->parseNotify($rawData); if ($parsed['status'] !== 'success') { return 'fail'; } return $this->processPaidOrder($parsed); }ChannelFactory是一个简单的工厂类,根据channel参数返回对应的适配器实例。每个适配器实现verifySign和parseNotify两个方法。verifySign负责用渠道自己的密钥和算法做验签,parseNotify负责把渠道返回的原始数据转成内部标准格式。这样改动后,新增一个渠道只需要新增一个适配器类,不用动任何回调业务逻辑。
改造过程中需要注意几点:第一,file_get_contents('php://input')拿到的原始报文是验签的基础,不要先用框架的解析方法读了一遍再去验签,那样拿到的可能不是原始数据。第二,processPaidOrder里做订单更新时,必须保证幂等,这是第 5 章说过的唯一索引问题,改造时可以一并解决。第三,回调适配器的日志必须打全,哪些参数参与了验签、验签结果是什么、原始报文是什么,全部记录到单独的runtime/log/notify.log。有了这个日志,即使出了问题也能按时间线还原现场。
从那以后,我每次接新渠道都把新增适配器当成例行公事,不再为每个渠道单独写一套回调入口。老渠道的代码全部靠适配器收敛,新渠道验证也快了许多,基本半天就能完成联调,而且再也没有出现过漏单和重复入账的情况。如果你也准备在这套源码上接多个渠道,建议按这个思路改一遍,希望帮到你。
本文还有配套的精品资源,点击获取