资讯动态

四方支付源码解析:基于ThinkPHP的支付网关设计与实践

发布时间:2026/10/19 2:42:33 来源:尧图企业网站定制
简介这是基于ThinkPHP框架开发的四方支付系统商业版源码面向需要快速搭建聚合支付平台的企业开发者与二次开发商支持支付宝、微信、银联等多渠道一站式接入并内置商户管理、订单处理、资金结算与风险控制等完整支付闭环。压缩包共2000个文件约47.73MB核心逻辑为849个PHP文件另有大量gif/png/jpg图片资源、html/js/css前端页面、dat数据文件及配置文件整体目录结构清晰便于按模块查阅与定制。目前已吸引2661人学习下载在中小型支付系统选型中有一定参考价值。资源附有完整的接口文件及API文档开发者可获得支付请求、回调处理、对账结算等关键环节的实现参考并能基于ThinkPHP的MVC架构快速扩展或改造适合具备PHP开发经验、希望自主掌控支付流程的团队实践使用。1. 四方支付源码别把聚合支付想成一层壳它其实是个可插拔的支付网关我有段时间接连接了多个支付项目最深的体会是换一个支付通道业务代码就要从头改一遍。后来拆了一套基于ThinkPHP的四方支付源码才真正意识到四方支付要解决的不是“多接几个通道”而是把通道差异、回调安全、账务一致这些脏活隔离到独立层。这套源码自带商户管理、订单管理、异步通知、定时查单、每日对账和清晰的结构化扩展点适合想快速搭起聚合支付平台的开发者也适合想看看策略模式和状态机怎么落到真实业务里的PHP后端。2. 技术选型与整体架构ThinkPHP如何撑起一个多通道支付系统2.1 四方支付要解决的核心问题通道差异、回调安全和账务一致四方支付的“四方”是相对第三方支付平台而言的。第三方支付直接跟商户和银行结算四方支付则夹在商户与多个第三方通道之间做统一接入、统一验签、统一通知。它不碰资金只维护一份订单账本所以整个系统的核心诉求是账务一致而不是资金安全。这一点很容易被误解很多人把四方支付当成一个多通道转发工具忽略了账务对账才是生命线。基于这个边界系统至少要拆出三个域。通道域负责把不同支付平台的接口差异封装起来对外暴露统一的下单、验签、退款、查单能力。业务域负责订单状态机、商户信息、费率和通知发送。管理域提供平台管理端和商户端维护渠道配置、密钥和IP白名单。三个域通过订单表和商户表串起来业务代码不直接访问通道接口而是通过一个工厂类去拿通道实例。我拆源码时有个习惯先看通道域能不能满足“新增一个通道不动业务代码”这个条件。如果整个项目里用大量 if...else 判断通道类型那这个四方支付多半只是个壳谈不上架构。合格的实现一定把通道看成一等公民每个通道是一个策略类所有通道遵守同一个接口。2.2 总体架构从商户请求到通道响应中间有三层这个源码的纵向链路可以理解成三层。最前面是接口层集中在 app/pay/controller 目录所有面向商户的支付接口都在这里收口负责参数校验、公共错误码、以及叫醒后续服务层。中间是服务层在 app/pay/service 目录下订单创建、退款申请、通知重试这些真正的业务逻辑都在这里服务层只认订单状态不认具体通道。最后是策略层在 app/pay/strategy 目录下每个通道一个类实现统一的 PayChannelInterface 接口。分层之后收益非常直接。你要加一个通道接口层和服务层一个字都不用改只需要在策略层新增一个类然后在工厂里注册。反过来某个通道的签名规则变了也只在对应策略类里修不会牵扯到订单生成逻辑。我见过不少项目把通道参数塞在下单方法里结果一个通道改字段所有业务代码跟着抖。用 ThinkPHP 做这个架子图的是它的几样现成能力验证器把参数校验写在入口命令行指令天然配合定时任务ORM 能少写很多 SQL。支付系统的特点是接口多、状态多、回调多、定时任务多这些在 ThinkPHP 里都能做到模块化挂载不需要再引入一套微服务框架。2.3 目录结构和数据库设计订单、商户、渠道三张表怎么落库拿到源码后建议先花一刻钟把目录读一遍。常见的四方支付源码会按模块拆成这样app ├── admin // 平台管理端 ├── index // 商户门户 ├── pay // 支付网关核心 │ ├── controller │ ├── service │ └── strategy ├── common // 公共模型和助手函数 └── api // 对外开放APIpay/strategy 是整套源码的灵魂。它下面每个通道类都需要实现四个能力pay发起支付、verify验签、refund退款、query查单。只要接口统一定义好后续扩展只是新增文件的问题。数据库层面最核心的是订单、商户、渠道、回调日志这四张表表名关键字段作用pay_orderorder_no, merchant_id, channel_code, amount, status, notify_status每笔支付请求的账本pay_merchantmerchant_id, api_key, secret_key, notify_url, return_url商户身份与密钥pay_channelchannel_code, channel_name, rate, status可用支付渠道配置pay_notify_logorder_no, request_data, notify_count, last_notify_time异步通知的原始记录pay_order 里的 status 建议用整数状态机0 待支付、1 支付成功、2 退款中、3 已关闭、4 已退款。金额字段 amount 用整数分存储不要用 float这个决定直接影响后面的对账能不能做平。pay_notify_log 看起来不起眼但回调排查和防重复通知都要靠它。每次收到回调先落库再走业务逻辑出问题能回溯到原始报文。建表的时候还要注意两个索引pay_order 的 order_no 唯一索引pay_order 的 merchant_id create_time 联合索引。前者是并发下单的防重手段后者是后台按商户查订单的核心路径。没有唯一索引后面代码里的幂等处理会失去最重要的兜底。3. 核心模块拆解一次支付从下单到异步回调的完整链路3.1 下单入口参数校验与订单生成第一次翻开这个源码建议从 PayController 的 createOrder 方法开始看。它把外部请求挡在最外面先做参数校验再交给服务层。?php namespace app\pay\controller; use app\pay\service\PayService; use think\Request; use think\exception\ValidateException; class PayController { public function createOrder(Request $request) { $data $request-post(); try { validate([ merchant_id require|integer, channel_code require|alphaDash, amount require|integer|between:1,100000000, notify_url require|url|max:255, sign require|length:32, ])-check($data); } catch (ValidateException $e) { return json([code 40001, msg $e-getError()]); } $orderNo $this-payService-createOrder($data); return json([code 0, data [order_no $orderNo]]); } }校验规则里有几个细节值得注意。amount 必须是整数最小 1最大限制到一亿元单位是分从入口就避免浮点数和超范围金额。sign 要求 32 位这是这类支付系统最常见的签名长度但真正的验签不会只靠长度判断要在服务层用商户密钥重新计算。notify_url 必须是 URL否则异步回调发不出去。这里顺带提一句实际上下单接口里还应该有一个对商户密钥的验签动作很多源码把它放在一个 BaseController 的构造方法里每个商户请求进来先验一次。验签失败的请求直接返回签名错误根本不会进到 createOrder。这样的好处是后续新增任何商户接口都能自动带上验签拦截而不是每个方法都写一遍。服务层 createOrder 做的事情也不复杂但顺序很重要先查商户是否存在且状态正常再生成订单号最后插入订单表。public function createOrder(array $data): string { $merchant Db::name(pay_merchant) -where(merchant_id, $data[merchant_id]) -find(); if (!$merchant || $merchant[status] ! 1) { throw new BizException(商户不存在或已被关闭); } $orderNo $this-generateOrderNo(); Db::name(pay_order)-insert([ order_no $orderNo, merchant_id $data[merchant_id], channel_code $data[channel_code], amount $data[amount], status 0, create_time time(), ]); return $orderNo; }订单号生成我这里会特别看一下。单纯用 uniqid() 在并发下会重复同一秒两个请求生成同一个订单号会导致唯一索引冲突。比较稳妥的做法是日期时间加随机数再拼上数据库自增ID的最后几位高级一点可以在内存里维护一个序列号。下面是一个够用的写法private function generateOrderNo(): string { $date date(YmdHis); $rand str_pad(random_int(0, 9999), 4, 0, STR_PAD_LEFT); return $date . $rand; }这个写法在单机低并发下能扛住大部分场景。如果真正上线后并发很高可以把随机数换成 Redis 自增序列或者干脆由数据库自增ID反哺订单号一部分。生成订单号后插入时如果碰到 Duplicate entry说明商户端重复提交了同一笔单要捕获异常并返回已有订单号而不是报错。这段逻辑放在服务层还是数据库层不是重点重点是必须兜住唯一索引冲突。3.2 通道选择策略模式下的渠道路由订单入库以后需要根据 channel_code 选择一个通道去发起支付。这个源码的做法是维护一个 ChannelFactory把通道编码映射到具体策略类。?php namespace app\pay\strategy; use app\pay\strategy\contract\PayChannelInterface; class ChannelFactory { private array $channels [ platform_a PlatformAChannel::class, platform_b PlatformBChannel::class, ]; public function make(string $channelCode): PayChannelInterface { if (!isset($this-channels[$channelCode])) { throw new \RuntimeException(不支持的支付通道); } $class $this-channels[$channelCode]; return new $class(); } }配合这个工厂的是一个接口约束namespace app\pay\strategy\contract; interface PayChannelInterface { public function pay(array $params): array; // 返回二维码内容或跳转URL public function verify(array $data): bool; // 对异步通知验签 public function refund(array $params): bool; // 发起退款 public function query(string $tradeNo): array; // 主动查单 }服务层在发起支付时只做一件事拿到通道实例调用 pay 方法。至于 platform_a 需要把参数按 ASCII 排序后拼 keyplatform_b 要做 RSA 验签这些差异全部藏在各自策略类里。新增通道时在 channels 数组加一行就够了这是策略模式在支付系统里最典型的应用场景。唯一要注意的是这个工厂不能做成上帝类如果通道数量膨胀到几十个应该改成配置驱动从 pay_channel 表读渠道编码映射到完整类名而不是在代码里维护一个越来越大的数组。3.3 生成支付页二维码和跳转链接的统一返回通道 pay 方法返回的数据源头上分两种一种返回二维码字符串一种返回跳转链接。服务层要做的是把它们转成一个稳定的响应结构前端才好统一渲染。$channel (new ChannelFactory())-make($order[channel_code]); $result $channel-pay([ order_no $order[order_no], amount $order[amount], notify_url $this-getNotifyUrl($channelCode), ]); if (isset($result[qr_code])) { return json([type qr_code, content $result[qr_code]]); } if (isset($result[redirect_url])) { return json([type redirect, url $result[redirect_url]]); }我在这类项目里吃过一个亏如果直接把通道返回的完整 HTML 塞给前端后面想统一改页面风格就非常麻烦。正确的做法是先把上游返回的支付标识解析成纯数据再承载到 response 结构里。二维码 content 可能是一个字符串 URL也可能是一串参数对前端来说都是 content 字段透明处理就好了。3.4 异步回调验签、防重复与状态机推进回调接口是整个四方支付源码里最容易写错的地方。它跟普通接口不一样上游通道会多次重试你必须在入口处理好三件事验签、幂等、状态流转。public function notify(string $channelCode, Request $request) { $payload $request-post(); $channel (new ChannelFactory())-make($channelCode); if (!$channel-verify($payload, $payload[sign] ?? )) { return fail; } $order Db::name(pay_order) -where(order_no, $payload[order_no] ?? ) -find(); if (!$order) { return fail; } if ($order[status] 0) { $updated Db::name(pay_order) -where(order_no, $order[order_no]) -where(status, 0) -update([ status 1, pay_time time(), channel_trade_no $payload[trade_no] ?? , ]); if ($updated) { $this-pushMerchantNotify($order); } } return success; }这段代码的关键在第4步的条件更新。where 条件里多一个 status 0意味着只有待支付订单能被更新成成功。如果是定时查单任务先更新了状态回调再来时更新行数为 0就不会触发重复通知。返回 success 也是告诉通道不要再重试但如果这里因为校验失败返回异常几乎所有通道都会继续重试所以验签失败要用 fail 而不是抛异常。回调原始报文最好先落一张 notify_log 表再走业务逻辑。后面排查问题直接查这张表就能看到通道到底发了什么比靠猜强得多。这个设计不复杂但很多只求功能跑通的项目都不做真出问题才到处抓包。落库的字段至少包括订单号、请求方IP、原始报文、接收时间、处理结果。4. 定时任务与对账主动查单、补单与系统稳定性4.1 超时订单扫单为什么除了回调还要主动去查异步回调再快也只是通知网络抖动、商户服务重启、防火墙拦截都可能导致通知丢失。四方支付系统必须有一个兜底机制定时扫描待支付订单主动去通道侧查询真实支付状态。常见做法是用 ThinkPHP 的独立指令进程处理不写进 Web 请求里。namespace app\pay\command; use think\console\Command; use think\console\Input; use think\console\Output; use think\facade\Db; class CheckTimeoutOrder extends Command { protected function configure() { $this-setName(pay:checkTimeout)-setDescription(扫描超时未支付订单); } protected function execute(Input $input, Output $output) { $deadline time() - 600; $orders Db::name(pay_order) -where(status, 0) -where(create_time, , $deadline) -limit(200) -select(); foreach ($orders as $order) { $this-queryOrder($order); } $output-writeln(check done); } }这里的 limit(200) 不是随意写的。一次拉太多订单会让通道的查询接口瞬间被打满尤其定时任务重启或者堆积大量订单时通道会直接拒掉请求。每5分钟跑一次、每次处理200单是大多数四方支付源码在中小并发下的安全值。如果要处理更高量级应该增加分页和游标方式。命令名配置在 config/console.php 里注册后通过php think pay:checkTimeout执行。cron 里写成*/5 * * * * /usr/bin/php /项目目录/think pay:checkTimeout就行。要注意不能让两个进程同时跑同一个扫单任务简单做法是在任务入口用 Redis 或数据库加一个锁拿到锁才执行。4.2 主动查单与状态修复并发更新的关键一步queryOrder 的任务是根据通道查询结果刷新本地订单状态。状态从0变成1或者变成3都必须使用条件更新。private function queryOrder(array $order) { $channel (new ChannelFactory())-make($order[channel_code]); $result $channel-query($order[order_no]); if ($result[status] 1) { $updated Db::name(pay_order) -where(order_no, $order[order_no]) -where(status, 0) -update([status 1, pay_time time()]); if ($updated) { $this-pushMerchantNotify($order); } } elseif ($result[status] 2) { Db::name(pay_order) -where(order_no, $order[order_no]) -where(status, 0) -update([status 3, close_time time()]); } }条件更新在这里有一个额外作用天然做了并发保护。定时任务在跑的同时异步回调可能也在处理同一笔订单。两个流程都读到 status 0但如果都直接用上一个流程读到的订单对象做 update第二个更新会覆盖第一个的 pay_time造成状态混乱。加上 where status 0 和判断影响行数只有一条流程能真正完成状态流转另一个更新行数为0时直接跳过。pushMerchantNotify 的实现通常会放进一个 beantask 队列或 notify_queue 表。通知也讲究幂等只有状态机真正更新成功的那一次流程才允许写通知否则商户会收到两条相同结果。如果通知失败还要有重试机制常见做法是记录 notify_count 和 last_notify_time超过最大重试次数后人工介入。4.3 每日对账通道账单与本地订单逐行比对对账是支付系统最后一道安全网。每天凌晨从通道侧拉取前一天的交易账单按订单号或者通道流水号逐行跟本地 pay_order 比对把不一致的记录抽出来。public function dailyReconciliation(string $date) { $billList $this-pullChannelBill($date); foreach ($billList as $bill) { $order Db::name(pay_order) -where(channel_trade_no, $bill[trade_no]) -find(); if (!$order) { $this-misMatchRecord[] [type no_local_order, trade_no $bill[trade_no]]; } elseif ($order[amount] ! $bill[amount]) { $this-misMatchRecord[] [type amount_mismatch, order_no $order[order_no]]; } } // 生成汇总报告推送给平台运营人员 }对账的金额比较必须用整数因为本地订单 amount 和通道账单的金额单位都是分。如果之前用了 float这里会不断出现一分钱两分钱的差异排查到最后通常是乘法的精度问题。对账单拉取频率一般一天一拉但量大的项目可以做成每小时增量对账。对账任务不适合放在 Web 请求里执行因为账单数据量大时会超出 PHP 默认运行时限。标准的姿态同样是指令任务在 cron 里配置php think pay:reconcile运行。对账结果可以输出成一个 CSV 文件也可以直接插表由后台页面展示不一致列表。无论哪种目标都是把差异暴露出来而不是自动修掉所有异常。4.4 避坑这些现象我在同类支付项目里见过不止一次现象一回调偶尔丢单但跑一遍查单任务就能补回来。原因是商户配置的 notify_url 写成了内网地址或者通知在网关层被拦掉。解决回调地址统一放到配置表里后台可灵活修改调试时用同网段可访问的域名不要用 localhost。现象二定时任务和回调同时更新同一笔订单商户被通知了两次。原因是两个流程各自做了 update没有条件约束。解决所有状态刷新都用 where status 0 的条件更新并检查 update 返回的影响行数。这也是这个源码里最值得学的写法。现象三每天对账都差几分钱。原因是下单时用 float 计算了手续费比如乘以 0.006 得到 0.599999。解决金额和费率全部换算成整数分PHP 里用 int 处理最后入库的也是 int。这是支付系统的底线约定不管下游通道文档怎么写本地账本必须坚持整数。现象四回调返回 success通道仍然不停重发。原因是通道要求返回 SUCCESS大写或者固定字符串。解决把回调返回值做成通道级别的配置在策略类里定义 notifySuccess() 和 notifyFail() 两个方法统一由接口层调用不要全局写死。注意在接入任何新通道前先看它文档里的回调返回协议写的是什么很多通道只看响应体内容不看状态码。现象五新增了通道工厂里也注册了类但管理后台看不到新通道。原因是只改了代码没往 pay_channel 表里插渠道配置。解决后台渠道管理在新增操作里同时写数据库配置和生成代码占位前端再读 pay_channel 表展示数据与代码分离而不是只靠硬编码。5. 安全设计与权限控制四方支付源码里值得反复看的细节5.1 商户签名机制请求到网关前先过验签四方支付源码里最基础的安全设计是商户签名。商户每次请求都带着 api_key、业务参数和一个 sign网关拿到参数后按既定规则重新计算一遍 sign比对通过才继续。public function verifySign(array $data, string $secretKey): bool { $sign $data[sign] ?? ; unset($data[sign]); ksort($data); $str urldecode(http_build_query($data)) . key . $secretKey; return md5($str) $sign; }这里的细节是去掉 sign 字段后用 ksort 保证拼接顺序两边一致才能算出相同签名。http_build_query 会对参数做 URL 编码所以计算前要 urldecode 一次不然中文参数和特殊字符会让两端签名不一致。secret_key 只在服务端保存不在请求中传输。md5 签名在今天的安全强度里已经不算高但胜在实现简单、兼容性好很多通道仍然在用。源码里通常会把验签逻辑收敛到通道策略类的 verify 方法这样以后要升级成哈希消息认证码或非对称加密替换位置非常集中。密钥管理是另一个值得学习的地方。优秀的四方支付源码会把 secret_key 用可逆加密后存入数据库后台列表和接口日志里只显示前几位字符比如sk_****abc123。这样即使数据库被拖走也拿不到能直接算签名的明文密钥。5.2 IP白名单与权限控制哪些接口需要额外拦一层商户后台登录、退款申请这类高风险操作通常需要额外限制来源 IP。源码里会维护一张 pay_merchant_ip 表在进入业务逻辑前先校验请求 IP 是否在允许范围。$allowedIps Db::name(pay_merchant_ip) -where(merchant_id, $order[merchant_id]) -column(ip); if (!in_array($request-ip(), $allowedIps)) { return json([code 403, msg IP not allowed]); }但回调接口不建议直接套用这个逻辑因为通道的回调来源 IP 可能经常变。常见的做法是把 IP 白名单做成一个配置项只在后台手动开启的商户上生效。普通商户仅靠验签和订单状态机已经能挡住绝大多数伪造请求。后台权限控制也会在管理端模块里分层。平台管理员能管理渠道和商户商户管理员只能查看自己名下的订单和退款记录。这类权限用规则的 RBAC 实现重点不是写多少条 else if而是把实体关系绑定清楚商户下的子账号不能越权读其它商户数据。数据隔离比接口隔离更容易被忽视支付项目里一旦越权就是资金层面的泄露。5.3 幂等处理并发下单、重复通知、重复退款支付系统的幂等体现在三个位置。第一个是下单商户端在网络超时后会重试同一个订单号可能被提交多次。订单表给 order_no 建唯一索引后第二次插入会抛 Duplicate entry源码会在捕获异常后把已有订单号返回出去。try { Db::name(pay_order)-insert($orderData); } catch (\Throwable $e) { if (strpos($e-getMessage(), Duplicate entry) ! false) { return $existingOrder[order_no]; } throw $e; }第二个是退款退款接口启动前必须检查订单状态只有 status 为1的已支付订单才能进入退款流程。退款发起成功后立即把状态改成2退款中通道退款成功回调后改为4已退款。这个状态机的好处是同一笔订单发起两次退款第二次进来看到状态不是1直接拒绝。第三个是通知商户通知要记录 already_notified 标记异步或定时任务同步了订单状态后不能无限通知。源码通常维护一个 notify_queue 表只有触发状态机变更的那一次流程会往里写入任务。幂等不是靠某一行代码实现而是靠状态机、唯一索引、条件更新三者叠加。退款回调的验签和支付回调同样重要。有些源码在支付回调上做了一堆校验到退款回调就只判断 result 字符串这是最容易翻车的地方。伪造一条退款成功通知就能把平台账本改乱。所以退款通知也要走 verify 入口并且更新退款状态时加条件where status 2这样只有待退款订单能被推进到已退款。6. 二次开发实战给四方支付源码接入一个新的支付通道6.1 定义通道类并实现统一接口接入新通道时先在 app/pay/strategy 目录新建一个类文件比如 PlatformCChannel.php。真正要做的事情是把通道文档里的下单参数、验签规则、退款规则翻译成四个方法。class PlatformCChannel implements PayChannelInterface { public function pay(array $params): array { // 调用通道下单接口返回二维码字符串 return [qr_code $this-request($params)]; } public function verify(array $data): bool { // 按通道要求排序、拼接、比较签名 return true; } public function refund(array $params): bool { // 调用通道退款接口 return true; } public function query(string $tradeNo): array { // 查询订单真实状态 return [status 1]; } }6.2 注册通道并完成配置在 ChannelFactory 的 $channels 数组加一行然后往 pay_channel 表插入渠道数据。两步缺一不可代码注册负责让接口层能调用到数据库配置负责让管理后台展示和开关。6.3 回归验证与我的一个小习惯通道接好后我会按“下单→二维码→沙箱支付→异步回调→定时查单→退款”的顺序走完整流程。每走一步核对一次订单状态和金额确认没有丢单、没有重复通知。从那以后我每次改支付相关代码都强制走一遍这套模拟流程再核对数据库里的金额与状态线上才会稳。这套源码的二次开发价值也恰好在这里看懂它的分层和状态机之后新增通道从“改业务代码”降级成“加一个策略类”希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑