简介:这份资源是基于PHP的PaySDK支付接口集成设计源码,面向需要为Web应用接入在线支付能力的PHP开发者,尤其适合希望快速集成支付宝、微信支付等主流渠道的中级开发者。项目以PHP与HTML为主要实现语言,兼容PHP 5.4及以上运行环境,可运行于各类支持PHP的系统。压缩包共173个文件,其中PHP文件170个,另含composer.json依赖配置、LICENSE许可协议与readme说明文档,整体约312KB,体积轻量便于快速部署与二次开发。源码覆盖支付接口调用、与支付服务商交互逻辑、数据处理及用户身份验证等关键环节,并附带宇润PHP全家桶技术支持渠道,方便集成过程中交流排错。目前已有271人学习浏览,适合作为支付模块开发、接口封装思路与项目结构参考的实践样本。
1. 从一份 PaySDK 源码说起:PHP 支付接口集成到底在集成什么
很多做 PHP 的朋友第一次接触支付接口,都是被一份「PaySDK 源码」砸到手里的。打开一看,目录里躺着alipay、wechat、unionpay几个文件夹,每个里面又有sdk、demo、cert、logs,文件几百个,注释还是半中半英。这时候最容易犯的错,就是直接找demo.php改改参数就上线,结果回调验签失败、金额对不上、退款状态永远查不到。PaySDK 这类东西,本质不是「一个能跑的脚本」,而是一套把下单、签名、回调验签、对账、退款这几件事统一封装的中间层。它要解决的核心问题是:让业务代码不直接碰各家支付网关的差异,只调一个统一入口。适合谁?适合手里有 PHP 项目、需要接至少两家支付渠道、又不想每个渠道写一套重复逻辑的团队。下面我按自己落地的顺序,把这份源码该怎么读、怎么改、怎么避坑讲清楚。
2. 拆开 PaySDK 源码:目录结构、核心类与签名机制
拿到一份 PaySDK 源码,先别急着跑 demo。我一般会花二十分钟把目录结构和核心类过一遍,这一步决定了后面改代码是「顺藤摸瓜」还是「到处救火」。
2.1 典型目录结构与各目录职责
一份结构比较规范的 PHP PaySDK,通常长这样:
paysdk/ ├── src/ │ ├── Gateway/ │ │ ├── AlipayGateway.php │ │ ├── WechatGateway.php │ │ └── UnionpayGateway.php │ ├── Contract/ │ │ └── GatewayInterface.php │ ├── Support/ │ │ ├── Signer.php │ │ ├── HttpClient.php │ │ └── Config.php │ └── Exception/ │ └── PayException.php ├── config/ │ └── pay.php ├── cert/ │ ├── alipay_app_cert.pem │ └── alipay_public_cert.pem ├── logs/ └── demo/ ├── alipay_demo.php └── wechat_demo.phpContract/GatewayInterface.php是整个 SDK 的契约层,它规定每个渠道必须实现哪些方法,常见的是pay()、refund()、query()、notify()、verify()。Gateway/下每个文件对应一个渠道,负责把统一参数翻译成该渠道要求的字段名。Support/Signer.php是签名工具,Support/HttpClient.php负责发请求,Config.php读配置。cert/放证书,logs/放日志,demo/是给你抄的示例。
提示:如果源码里没有
Contract层,所有渠道类各写各的,那这份 SDK 的维护成本会很高,建议先补一个接口再往下做。
2.2 签名与验签:PaySDK 里最容易翻车的一段
支付接口的签名逻辑,是整份源码里最不能想当然的部分。不同渠道的签名算法、参数排序规则、编码方式都不一样。以常见的 RSA2 签名为例,核心步骤是:把参数按 key 升序排列,拼成k1=v1&k2=v2形式,用私钥签名,回调时用平台公钥验签。
<?php // Support/Signer.php 片段 class Signer { // 生成待签名字符串:过滤空值、按 key 升序、拼接 public static function buildSignContent(array $params): string { ksort($params); // 参数名升序,这是硬性要求 $pairs = []; foreach ($params as $k => $v) { if ($v === '' || $v === null) { continue; // 空值不参与签名 } if ($k === 'sign' || $k === 'sign_type') { continue; // 签名字段本身不参与 } $pairs[] = $k . '=' . $v; } return implode('&', $pairs); } // RSA2 签名 public static function rsa2Sign(string $content, string $privateKey): string { $res = openssl_get_privatekey($privateKey); openssl_sign($content, $sign, $res, OPENSSL_ALGO_SHA256); openssl_free_key($res); return base64_encode($sign); // 结果必须 base64 } // 回调验签 public static function rsa2Verify(string $content, string $sign, string $publicKey): bool { $res = openssl_get_publickey($publicKey); $ok = openssl_verify($content, base64_decode($sign), $res, OPENSSL_ALGO_SHA256); openssl_free_key($res); return $ok === 1; } }逻辑说明:buildSignContent里ksort和过滤空值是两个关键点,少一个都会导致验签失败。rsa2Sign用OPENSSL_ALGO_SHA256,对应支付宝的 RSA2;如果渠道要求 RSA(SHA1),这里要换成OPENSSL_ALGO_SHA1。rsa2Verify返回1才算成功,0是验签不通过,-1是出错,别用if ($ok)直接判断,因为-1也是真。
参数说明:$privateKey是应用私钥内容,不是文件路径,读文件用file_get_contents;$publicKey是平台公钥,回调验签必须用平台公钥而不是自己的公钥,这一点新手经常搞反。
2.3 统一入口类怎么设计
业务代码不应该直接 new 某个渠道类,而是通过一个工厂或容器拿实例。常见做法是:
<?php // src/PayFactory.php class PayFactory { public static function make(string $channel): GatewayInterface { $config = require __DIR__ . '/../config/pay.php'; $class = 'Gateway\\' . ucfirst($channel) . 'Gateway'; if (!class_exists($class)) { throw new PayException("渠道 {$channel} 未实现"); } return new $class($config[$channel]); } }这样业务侧只写PayFactory::make('alipay')->pay($order),换渠道只改一个字符串。参数说明:$channel建议用白名单校验,不要直接拼类名,否则存在被构造恶意类名的风险。$config[$channel]里至少要有app_id、private_key、public_key、notify_url、return_url这几项。
3. 从零跑通一笔支付:下单、回调、查单的最小闭环
原理清楚之后,就要动手跑通一笔真实支付。我一般会先在沙箱环境把「下单 → 支付 → 回调 → 查单」这条链路走通,再切生产。这一步跑不通,后面所有优化都是空谈。
3.1 下单接口的参数拼装与调用
下单是整条链路的起点。以支付宝当面付或手机网站支付为例,公共参数和业务参数要分开拼:
<?php // Gateway/AlipayGateway.php 片段 public function pay(array $order): array { $bizContent = [ 'out_trade_no' => $order['order_no'], // 商户订单号,必须唯一 'total_amount' => number_format($order['amount'], 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'] = Signer::rsa2Sign( Signer::buildSignContent($params), $this->config['private_key'] ); return $params; // 页面跳转类接口,前端拿这个拼 URL }逻辑说明:biz_content必须是 JSON 字符串,且中文不要转义成\uXXXX,否则部分渠道会报参数错误。total_amount用number_format强制两位小数,避免0.1 + 0.2这类浮点问题导致金额校验失败。out_trade_no是幂等的关键,同一笔订单重复下单要用同一个号。
参数说明:method决定调哪个接口,页面跳转和 APP 支付、扫码支付的method都不同,改错会直接报「接口不存在」。timestamp一般要求与服务器时间差不超过 15 分钟,服务器时间不准会直接失败。
3.2 异步回调验签与幂等处理
回调是支付里最需要小心的地方,因为它会被重复推送。核心原则:先验签,再判断订单状态,最后做幂等。
<?php // notify.php 回调入口 $raw = $_POST; $sign = $raw['sign'] ?? ''; unset($raw['sign'], $raw['sign_type']); $content = Signer::buildSignContent($raw); if (!Signer::rsa2Verify($content, $sign, $config['alipay_public_key'])) { exit('fail'); // 验签失败,直接返回 fail } // 幂等:用订单号加锁,避免重复处理 $lockKey = 'pay_notify_' . $raw['out_trade_no']; if (!Redis::set($lockKey, 1, ['nx', 'ex' => 30])) { exit('success'); // 已有处理中,直接返回成功 } if ($raw['trade_status'] === 'TRADE_SUCCESS') { // 更新订单状态,注意这里要用事务或乐观锁 Order::where('order_no', $raw['out_trade_no']) ->where('status', 0) ->update(['status' => 1, 'pay_time' => $raw['gmt_payment']]); } exit('success'); // 必须返回 success,否则平台会一直重推逻辑说明:验签前要把sign和sign_type从参数里剔除,否则拼出来的待签名串和平台算的不一致。幂等用 Redis 的nx锁,防止同一笔回调并发进来重复改单。返回success是告诉平台「我收到了,别再推了」,返回其他内容平台会按策略重试。
参数说明:trade_status常见值有TRADE_SUCCESS、TRADE_FINISHED、WAIT_BUYER_PAY,只有前两个代表钱到账。gmt_payment是到账时间,落库时用它而不是当前时间。
3.3 主动查单:回调丢了怎么办
回调可能因为网络、部署、代码异常而丢失,所以必须有主动查单兜底。常见做法是定时任务扫描「已下单未支付」的订单,超过一定时间就调查单接口。
<?php // 定时任务:每 2 分钟查一次 5 分钟前未支付的订单 $orders = Order::where('status', 0) ->where('created_at', '<', date('Y-m-d H:i:s', time() - 300)) ->limit(100) ->select(); foreach ($orders as $order) { $result = PayFactory::make('alipay')->query($order['order_no']); if (($result['trade_status'] ?? '') === 'TRADE_SUCCESS') { // 补单逻辑,和回调里保持一致 Order::where('order_no', $order['order_no']) ->where('status', 0) ->update(['status' => 1, 'pay_time' => $result['send_pay_date']]); } }逻辑说明:查单结果要和回调走同一套补单逻辑,最好抽成一个OrderService::markPaid()方法,避免两处逻辑不一致。limit(100)是防止一次拉太多把接口打爆。
参数说明:查单接口一般用商户订单号或平台交易号,二选一即可。查询频率不要太高,渠道通常有频率限制,2 分钟一次比较稳妥。
4. 多渠道路由与配置管理:让 PaySDK 真正可维护
单渠道跑通只是及格线,真正体现 PaySDK 价值的是多渠道路由和配置管理。这一章讲怎么把「能跑」变成「好维护」。
4.1 配置文件怎么组织才不混乱
配置最忌讳散落在代码各处。我一般会集中到一个config/pay.php,按渠道分组:
<?php return [ 'alipay' => [ 'app_id' => env('ALIPAY_APP_ID'), 'private_key' => file_get_contents(base_path('cert/alipay_app_private.pem')), 'public_key' => file_get_contents(base_path('cert/alipay_public.pem')), 'notify_url' => env('APP_URL') . '/notify/alipay', 'return_url' => env('APP_URL') . '/return/alipay', 'sandbox' => env('APP_ENV') !== 'production', ], 'wechat' => [ 'mch_id' => env('WECHAT_MCH_ID'), 'app_id' => env('WECHAT_APP_ID'), 'api_key' => env('WECHAT_API_KEY'), 'cert_path' => base_path('cert/wechat_apiclient_cert.pem'), 'key_path' => base_path('cert/wechat_apiclient_key.pem'), 'notify_url' => env('APP_URL') . '/notify/wechat', ], ];逻辑说明:密钥内容用file_get_contents读进来,而不是在代码里写路径到处读,这样渠道类只关心内容。sandbox开关用来切换沙箱和生产网关地址,避免上线时改代码。
参数说明:env()是常见框架的环境变量读取方式,没有框架的话用getenv()。证书路径建议用绝对路径,相对路径在不同入口文件下会解析到不同位置,这是血泪经验。
4.2 渠道路由与降级策略
多渠道路由一般有两种:用户指定和系统推荐。用户指定就是前端传channel,系统推荐则根据金额、用户历史、渠道可用性来选。
<?php class PayRouter { // 渠道优先级,按可用性和费率排 private array $priority = ['wechat', 'alipay', 'unionpay']; public function route(array $order): string { foreach ($this->priority as $channel) { if ($this->isAvailable($channel) && $this->supports($channel, $order)) { return $channel; } } throw new PayException('无可用支付渠道'); } private function isAvailable(string $channel): bool { // 读缓存里的渠道健康状态,失败次数超阈值就熔断 $failCount = Redis::get("pay_fail_{$channel}") ?: 0; return $failCount < 5; } }逻辑说明:isAvailable用失败计数做简单熔断,某渠道连续失败 5 次就暂时跳过,过一段时间再放量试探。supports用来判断渠道是否支持该订单类型,比如某些渠道不支持大额或跨境。
参数说明:priority顺序可以按费率调整,费率低的排前面。熔断阈值 5 是经验值,太小容易误熔断,太大起不到保护作用。
4.3 日志与对账:出了问题能查
支付出问题最怕「查不到」。日志要记录请求参数、响应、签名串、耗时,但不能记录完整密钥和用户敏感信息。
<?php // Support/HttpClient.php 片段 public function post(string $url, array $params): array { $start = microtime(true); $resp = $this->curlPost($url, $params); $cost = round((microtime(true) - $start) * 1000, 2); // 脱敏后再落日志 $logParams = $params; unset($logParams['sign']); if (isset($logParams['biz_content'])) { $logParams['biz_content'] = '[omitted]'; } Log::info('pay_request', [ 'url' => $url, 'params' => $logParams, 'resp' => $resp, 'cost_ms'=> $cost, ]); return $resp; }逻辑说明:sign和biz_content脱敏,避免日志泄露。cost_ms记录耗时,方便排查慢请求。对账则是每天拉平台账单和本地订单比对,差异单人工介入。
参数说明:日志建议按天切割,保留至少 90 天,支付纠纷追溯期通常较长。对账文件一般是 CSV 或文本,解析时注意编码和分隔符。
5. PaySDK 集成避坑:5 个真实踩过的坑
这一章是我和团队在多个项目里踩出来的,每条都按「现象 → 原因 → 解决」写,能帮你省下不少排查时间。
5.1 回调验签一直失败,但参数看着都对
现象:回调进来,buildSignContent拼出来的串和平台文档示例一模一样,验签就是返回 0。
原因:最常见的是$_POST被框架或中间件处理过,比如自动 trim 或转义,导致某个参数值和平台发来的不一致。另一个原因是content-type是application/json时,$_POST是空的,得从php://input读原始 body。
解决:回调入口先打印原始php://input和$_POST对比,确认参数没被改。如果是 JSON body,用json_decode(file_get_contents('php://input'), true)取参数。验签用的公钥确认是平台公钥,不是自己的应用公钥。
5.2 金额对不上,差一分钱
现象:下单金额 100.00,回调回来total_amount是 100,验签通过但金额校验失败。
原因:浮点数在 JSON 序列化和反序列化过程中精度丢失,或者平台返回的金额去掉了末尾的 0。
解决:金额统一用「分」为单位在内部流转,只在调接口时转成元。比较金额时用bccomp或转成整数分再比,不要用==直接比浮点。
5.3 重复回调导致订单被改两次
现象:同一笔订单,回调进来两次,订单状态被改了两次,积分发了两次。
原因:平台重推机制 + 没有幂等控制。网络抖动、处理超时都会触发重推。
解决:用订单号做唯一索引或 Redis 锁,处理前先判断状态。数据库更新用where status = 0条件更新,影响行数为 0 就说明已处理过,直接返回成功。
5.4 沙箱能跑,生产报「签名错误」
现象:沙箱环境一切正常,切到生产后所有请求都报签名错误。
原因:沙箱和生产的密钥是两套,网关地址也不同。切换时只改了网关地址,密钥没换。
解决:配置里把沙箱和生产的密钥、网关地址、app_id 全部成组管理,用环境变量或配置开关整体切换,不要手动改单个字段。
5.5 证书路径在 CLI 下找不到
现象:Web 访问正常,定时任务查单时报「证书文件不存在」。
原因:相对路径./cert/xxx.pem在 Web 入口下解析到项目根目录,在 CLI 下解析到执行目录,两者不一致。
解决:证书路径统一用__DIR__或框架的base_path()拼绝对路径。部署时确认证书文件权限,CLI 运行用户要有读权限。
6. 进阶:把 PaySDK 做成可测试、可扩展的支付层
跑通和避坑之后,如果还想让这套 PaySDK 走得更远,就得在可测试和可扩展上下功夫。这一章讲两个我常用的技巧。
6.1 用接口 + 假实现做单元测试
支付接口没法在单测里真发请求,所以要用假实现替换。核心是面向GatewayInterface编程:
<?php class FakeGateway implements GatewayInterface { public array $calls = []; public function pay(array $order): array { $this->calls[] = ['pay', $order]; return ['code' => 10000, 'msg' => 'ok']; } public function query(string $orderNo): array { return ['trade_status' => 'TRADE_SUCCESS', 'send_pay_date' => date('Y-m-d H:i:s')]; } // refund / notify / verify 同理 } // 测试里 $fake = new FakeGateway(); $service = new OrderService($fake); $service->createOrder(['order_no' => 'T001', 'amount' => 1.00]); $this->assertSame('pay', $fake->calls[0][0]);逻辑说明:假实现记录调用,断言业务逻辑有没有正确调支付。真实渠道类只在集成测试里跑,单测全部用假实现,速度快且稳定。
参数说明:FakeGateway的返回值要覆盖成功、失败、异常三种情况,测试里分别构造。接口方法签名一旦定下,所有渠道和假实现都要同步,所以接口要尽量精简。
6.2 新增一个渠道要改哪些地方
新增渠道是检验 PaySDK 设计好坏的试金石。设计得好,只加一个类和一个配置;设计得差,要改十几处。
| 改动位置 | 是否必须 | 说明 |
|---|---|---|
Gateway/XxxGateway.php | 必须 | 实现GatewayInterface全部方法 |
config/pay.php | 必须 | 加该渠道的配置分组 |
PayFactory | 一般不用 | 工厂用类名约定,自动映射 |
PayRouter | 按需 | 如果参与路由,加进优先级列表 |
| 回调入口 | 必须 | 新增/notify/xxx路由 |
| 对账脚本 | 按需 | 如果需要对账,加解析逻辑 |
如果发现新增渠道要改超过 4 个文件,说明抽象层没做好,值得回头重构。我自己的习惯是:每接一个新渠道,先写接口实现,再写假实现跑单测,最后接沙箱联调。这样即使渠道文档写得含糊,也能靠单测把逻辑先固定下来。支付这块没有后悔药,上线前多测一遍,比上线后半夜爬起来查单强得多。希望帮到你。
本文还有配套的精品资源,点击获取