PHP聚合支付系统源码实战:代付下单、卡密核销与回调对账全流程
简介:这套源码面向电商支付系统开发者与虚拟卡密店铺运营者,整合了淘宝天猫代付、京东油卡卡密及聚合支付三类模块,可支持卡密店铺的协议回调与自动发卡流程。资源包共2000个文件,以1412个js脚本、209个html页面、117个css样式、111个json配置为主,另含131个md说明文档、2个sql建表文件及少量docx、pptx资料,压缩包约75.33MB,整体结构接近可直接部署的完整项目。需注意系统实际仅完成天猫代付与京东中石油模块,京东中石化、比心、快手小店等仅有名称占位;天猫模块附带ck软件与使用教程,中石油模块需自行研究调试。目前已有157人学习下载,适合具备一定后端与前端基础、希望快速搭建卡密代付与聚合支付平台的开发者参考,可借此理解协议回调、订单自动处理与多支付渠道接入的实现思路。
1. 代付与卡密聚合系统:一套源码能跑通哪些真实业务
电商大促那几天,最怕的不是订单量涨,而是支付环节掉链子。买家想用代付、想用油卡卡密结算,后台却要对接三四个渠道,每个渠道的签名规则、回调格式、对账口径都不一样。这套「淘宝天猫代付系统 / 京东油卡卡密系统 / 聚合支付系统源码」要解决的,就是把代付下单、卡密核销、聚合收单这三条线收进同一套后台,用统一订单模型和回调网关去对接上游。它适合谁?做电商周边工具的小团队、需要给客户搭一套代付中台的开发者、以及想研究聚合支付订单流转的 PHP 后端。源码本身是 PHP 技术栈,常见做法是 Nginx + PHP 7.x + MySQL 5.7 起步,前台下单、后台管理、异步回调三块分离。下面按「能解决什么 → 怎么部署 → 订单怎么流转 → 坑在哪 → 怎么验证」的顺序拆开讲,每一步都落到能抄的配置和代码。
2. 环境搭建与目录结构:把源码跑起来的第一公里
2.1 运行环境选型与依赖清单
这套源码是典型的 PHP 单体应用,别一上来就想着容器化,先把最朴素的 LEMP 跑通再说。选型理由很直接:代付和卡密业务对并发要求不算极端,瓶颈通常在渠道回调的同步处理上,PHP-FPM 配合 MySQL 足够撑住中小体量。常见做法是 PHP 7.4,因为部分老代码用了 each() 之类的函数,PHP 8 会直接报错,这是血泪经验,别问我怎么知道的。
依赖清单如下,装之前先核对版本:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| PHP | 7.2 ~ 7.4 | 8.0+ 需改废弃函数 |
| MySQL | 5.7 / 8.0 | 注意 utf8mb4 排序规则 |
| Nginx | 1.18+ | 负责静态与伪静态 |
| Redis | 5.0+ | 订单锁与回调去重 |
| Composer | 2.x | 装第三方 SDK |
PHP 扩展必须开 pdo_mysql 、 curl 、 openssl 、 bcmath 、 redis 。 bcmath 容易被忽略,但金额计算一旦用浮点,对账时就会出现一分钱的玄学差异,后面排查能让你怀疑人生。
2.2 目录结构与关键文件定位
解压后先别急着改代码,把目录摸清楚。典型结构长这样:
project/
├── application/ # 业务逻辑
│ ├── admin/ # 后台管理模块
│ ├── api/ # 对外下单接口
│ ├── notify/ # 渠道异步回调入口
│ └── common/ # 公共函数与订单模型
├── config/
│ └── database.php # 数据库与 Redis 配置
├── public/
│ └── index.php # 唯一入口
├── runtime/ # 日志与缓存,需可写
└── sql/
└── install.sql # 建表脚本
notify 目录是整套系统的咽喉,所有渠道的回调都从这里进,订单状态机也在这里被推动。部署时先把 runtime 权限给足,否则日志写不进去,回调失败你连现场都看不到。
2.3 数据库初始化与配置落地
导入建表脚本,然后改配置。这一步别偷懒,配置写错后面全是坑:
# 导入数据库结构
mysql -uroot -p your_db < sql/install.sql
# 给运行时目录写权限
chmod -R 755 runtime
chown -R www:www runtime
// config/database.php 关键片段
return [
'host' => '127.0.0.1',
'port' => 3306,
'user' => 'db_user',
'password' => 'db_pass',
'dbname' => 'pay_center',
'charset' => 'utf8mb4',
// Redis 用于订单锁,防止重复回调
'redis' => [
'host' => '127.0.0.1',
'port' => 6379,
'auth' => '',
],
];
参数说明: charset 必须是 utf8mb4 ,卡密里可能带特殊字符;Redis 的 auth 如果线上开了密码,这里留空会直接连不上,回调去重就失效,重复发货的风险随之而来。配置改完,访问 public/index.php 对应的域名,能看到登录页就算第一公里跑通了。
3. 代付下单与卡密核销:订单状态机怎么流转
3.1 代付下单接口的参数与签名
代付的核心是「我替买家把钱付给上游,上游回调告诉我成功」。下单接口一般放在 api 模块,接收商户订单号、金额、代付渠道、回调地址,然后做签名校验。签名规则通常是参数按字典序拼接后加盐做 MD5 或 HMAC-SHA256,具体看源码里的 common/Sign.php 。
// application/api/controller/Order.php 下单核心逻辑
public function create()
{
$params = input('post.');
// 1. 必填校验
if (empty($params['out_trade_no']) || empty($params['amount'])) {
return json(['code' => 400, 'msg' => '参数缺失']);
}
// 2. 金额统一转成分,避免浮点误差
$amountFen = bcmul($params['amount'], 100);
// 3. 验签,防止篡改
if (!Sign::verify($params, $params['sign'])) {
return json(['code' => 401, 'msg' => '签名错误']);
}
// 4. 落库,状态置为待支付
$orderNo = OrderModel::createOrder($params['out_trade_no'], $amountFen);
// 5. 请求上游代付渠道
$result = PayChannel::dispatch($params['channel'], $orderNo, $amountFen);
return json($result);
}
逻辑说明:金额用 bcmul 转成分是硬性要求,浮点直接参与运算迟早对不上账。验签放在落库之前,避免脏数据进库。 PayChannel::dispatch 是渠道分发器,根据 channel 参数路由到不同上游,这是聚合支付的关键抽象点。参数上, out_trade_no 是商户侧唯一号,必须做唯一索引,否则重复下单会生成两笔订单。
3.2 卡密核销的库存扣减与并发控制
京东油卡卡密这类业务,本质是「先扣库存,再发卡密,回调确认」。库存扣减必须用数据库行锁或 Redis 原子操作,否则大促时超卖是必然的。常见做法是用 SELECT ... FOR UPDATE 锁住卡密批次行,再取一条未使用的卡密。
// application/common/service/CardService.php 卡密核销
public function consume($batchId, $orderNo)
{
Db::startTrans();
try {
// 行锁锁定批次,防止并发超卖
$batch = Db::name('card_batch')
->where('id', $batchId)
->lock(true)
->find();
if ($batch['stock'] <= 0) {
throw new Exception('库存不足');
}
// 取一条未使用卡密并标记占用
$card = Db::name('card_item')
->where('batch_id', $batchId)
->where('status', 0)
->find();
Db::name('card_item')
->where('id', $card['id'])
->update(['status' => 1, 'order_no' => $orderNo]);
// 扣减批次库存
Db::name('card_batch')
->where('id', $batchId)
->setDec('stock', 1);
Db::commit();
return $card['card_no'];
} catch (Exception $e) {
Db::rollback();
return false;
}
}
逻辑说明: lock(true) 生成 FOR UPDATE ,把批次行锁住,同一时刻只有一个请求能扣库存。卡密状态用 0/1/2 表示未用、占用、已用,回调成功后再把 1 改成 2,这样即使发货中途失败也能回滚。参数上 batchId 对应卡密批次, orderNo 用于追溯。注意事务里不要做 HTTP 请求,否则锁持有时间过长,并发直接崩。
3.3 异步回调与订单状态推进
上游回调进来后,第一件事是验签,第二件事是幂等。幂等靠订单状态判断,已成功的订单直接返回 success ,不再重复处理。
// application/notify/controller/Index.php 回调处理
public function handle()
{
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
// 1. 验签
if (!Sign::verifyNotify($data)) {
exit('sign error');
}
// 2. 幂等:已处理直接返回
$order = Db::name('order')->where('order_no', $data['order_no'])->find();
if ($order['status'] == 2) {
exit('success');
}
// 3. 更新订单状态并触发发货
Db::name('order')->where('order_no', $data['order_no'])
->update(['status' => 2, 'pay_time' => time()]);
// 4. 卡密类订单确认卡密
if ($order['type'] == 'card') {
CardService::confirm($order['order_no']);
}
exit('success');
}
逻辑说明:回调入口必须返回上游约定的成功标识,否则上游会不断重推。幂等判断放在最前面,避免重复发货。 confirm 把卡密状态从占用改成已用。参数上 order_no 是内部订单号,和商户订单号要能互相映射,建议建一张映射表,别只靠一个字段硬扛。
4. 聚合支付渠道对接:签名、回调、对账三件套
4.1 多渠道配置的抽象与分发
聚合支付的难点不在单个渠道,而在渠道多了之后配置散落各处。合理做法是建一张 pay_channel 表,把渠道编码、网关地址、商户号、密钥、回调地址都存进去,代码里只认渠道编码。
| 字段 | 含义 | 示例 |
|---|---|---|
| channel_code | 渠道编码 | jd_oil、tmall_daifu |
| gateway | 上游网关 | https://up.example.com/pay |
| merchant_id | 上游商户号 | M202401 |
| secret | 签名密钥 | 存加密后的值 |
| notify_url | 回调地址 | https://your.com/notify |
分发器根据 channel_code 查配置,再调用对应渠道类。新增渠道时只加配置和渠道类,不动主流程,这是聚合系统能扩展的前提。
4.2 签名与验签的统一实现
签名规则各渠道不同,但结构可以统一:排序、拼接、加盐、加密。把差异收敛到每个渠道类的 sign() 方法里。
// application/common/library/Sign.php
public static function make(array $params, string $secret): string
{
// 1. 去掉签名本身和空值
unset($params['sign']);
$params = array_filter($params, function ($v) {
return $v !== '' && $v !== null;
});
// 2. 按 key 字典序排序
ksort($params);
// 3. 拼接成 k=v&k=v
$str = urldecode(http_build_query($params));
// 4. 加盐做 HMAC-SHA256
return hash_hmac('sha256', $str, $secret);
}
逻辑说明: http_build_query 默认会 urlencode,所以先 urldecode 还原,否则签名对不上。 array_filter 去掉空值,很多渠道要求空参数不参与签名。参数 secret 从渠道配置读取,不要硬编码在代码里。验签就是拿同样规则算一遍再比对,比对用 hash_equals 防时序攻击。
4.3 对账文件的生成与差异处理
对账是聚合系统最容易被忽视的一环。每天凌晨拉上游对账文件,和本地订单逐笔比对,差异单独落表。常见做法是写一个定时脚本,用 diff 思路处理。
# crontab 每天凌晨 2 点跑对账
0 2 * * * /usr/bin/php /www/project/script/reconcile.php >> /www/logs/reconcile.log 2>&1
// script/reconcile.php 核心比对
$local = Db::name('order')->where('pay_time', 'between', [$start, $end])->select();
$remote = parseUpstreamFile($filePath); // 解析上游对账文件
foreach ($local as $order) {
if (!isset($remote[$order['order_no']])) {
// 本地有、上游无:可能上游漏单
Db::name('reconcile_diff')->insert([
'order_no' => $order['order_no'],
'type' => 'local_only',
'amount' => $order['amount'],
]);
}
}
逻辑说明:对账要处理三种差异——本地有上游无、上游有本地无、金额不一致。每种都落表并告警,人工介入。参数上时间区间用 pay_time 而不是创建时间,因为只有支付成功的订单才需要对账。差异表要保留原始金额,方便财务核对。
5. 避坑与排查:那些让回调静默失败的细节
5.1 回调地址被框架路由拦截
现象:上游显示回调成功,但本地订单一直是待支付。原因:回调地址走了带鉴权的路由,或者被伪静态规则重写到了别处。解决:把 notify 入口单独配 Nginx location,跳过框架的登录中间件,并在日志里打印原始请求体确认能收到。
5.2 金额浮点导致对账差一分
现象:订单金额 99.99,对账时上游是 9999 分,本地算出来 9998。原因: float 参与乘法后精度丢失。解决:所有金额入库前统一用 bcmul 转成分存整数,展示时再除 100,全链路禁止浮点运算。
5.3 Redis 锁未释放导致订单卡死
现象:某笔订单一直占用,后续同批次卡密取不出来。原因:加锁后程序异常退出,没走到释放逻辑。解决:给锁设过期时间,用 SET key value NX EX 30 ,并在 finally 里兜底删除,别只依赖业务代码正常执行。
5.4 卡密状态回滚不彻底
现象:发货失败后卡密仍是占用状态,库存对不上。原因:只改了订单状态,没回滚卡密。解决:把订单状态和卡密状态放在同一个事务里,失败一起回滚,或者写补偿脚本定时扫描超时占用。
5.5 上游回调重复推送触发重复发货
现象:同一订单发了两张卡密。原因:幂等判断用了缓存但缓存过期,或者判断和更新之间有并发窗口。解决:幂等判断和状态更新放在一个事务里,用数据库唯一约束兜底,别只信缓存。
6. 上线前的验证清单与一个压测技巧
部署完别急着接真实渠道,先用沙箱把主流程走通。验证顺序建议是:本地下单 → 模拟回调 → 卡密核销 → 对账比对。模拟回调可以用 curl 直接打 notify 入口,构造合法签名,看订单状态是否推进。
# 模拟上游回调,验证幂等与状态推进
curl -X POST https://your.com/notify/index \
-H "Content-Type: application/json" \
-d '{"order_no":"T20240101001","amount":"99.99","status":"success","sign":"xxxx"}'
压测环节有个技巧:别用真实渠道,把 PayChannel::dispatch 临时改成直接返回成功,然后用 ab 或 wrk 打下单接口,观察订单表和卡密表的锁竞争。我一般会重点看两个指标——下单接口 P99 延迟和卡密扣减的失败率。如果失败率随并发上升,八成是行锁粒度过大,考虑把批次拆小或改用 Redis 预扣库存。
# 200 并发、总共 2000 请求压下单接口
ab -n 2000 -c 200 -p order.json -T application/json https://your.com/api/order/create
参数说明: -c 是并发数,别一上来就拉满,先 50 再 200 逐步加,观察 MySQL 的 innodb_row_lock_waits 。 order.json 里放合法签名参数,否则全被验签挡掉,压测没意义。
从那以后我每次上线这类系统,都会强制走一遍「沙箱回调 → 并发压测 → 对账比对」三步,少一步都不敢接真实流量。希望帮到你。
更多推荐



所有评论(0)