简介:这套源码面向电商支付场景的开发者与商家,整合了淘宝天猫代付、京东油卡卡密及聚合支付三类模块,可用于虚拟卡密店铺的自动发货与协议回调。系统支持淘宝天猫卡密店铺对接,天猫代付模块附带CK获取软件与使用教程,上手门槛相对较低;京东中石油模块则需自行研究调试。需注意,京东中石化、比心、快手小店等仅保留名称入口,功能尚未开发完成。资源包共约2000个文件,以1412个js脚本、209个html页面、131个md说明文档、117个css样式及111个json配置为主,另含sql建表脚本、sh部署脚本与docx、pptx说明材料,压缩包约75.33MB,目录结构完整,便于二次开发与模块定位。目前已有157人学习关注。对于需要搭建卡密代付与聚合支付流程的开发者而言,可借此快速理解支付回调、卡密分发与多平台接入的整体实现思路,并在此基础上按需扩展未完成模块。
1. 代付、卡密与聚合支付:一套源码要同时扛住三条业务线
淘宝天猫代付、京东油卡卡密、聚合支付,这三个词放在同一个源码标题里,不是拼凑,而是很多中小团队真实的业务组合。代付解决的是「我下单、别人付款」的链路,油卡卡密解决的是「虚拟商品即时交付」的链路,聚合支付解决的是「多个通道统一收单」的链路。三条线共用一套订单、账户、回调、对账底座,才是这类源码真正的价值所在。
我接触过的团队里,有人拿它做电商代付工具,有人拿它做虚拟卡券自动发货,也有人把它当聚合支付的学习脚手架。适合谁?适合已经懂 PHP 或 Java 基础、想研究支付订单状态机、想搞明白异步回调怎么防重放的人。不适合谁?不适合想直接上线收钱却不愿做风控和对账的人。这篇就按「先立住模型、再跑通链路、最后讲坑」的顺序,把这类源码拆开讲清楚。
2. 三条业务线共用的订单与账户模型
2.1 代付、卡密、聚合支付到底差在哪
很多人一上来就翻源码找支付接口,结果被一堆表名绕晕。先把三条线的业务本质分清楚,后面看代码才有方向。
代付的核心是「付款方和下单方分离」。A 发起订单,B 确认并支付,系统要记录发起人、付款人、订单金额、代付状态。它的状态机比普通支付多一层:待认领、已认领、待支付、已支付、已关闭。淘宝天猫代付场景里,最常见的是把订单号生成一个短链或口令,付款方打开后走支付通道。
京东油卡卡密的核心是「虚拟商品库存与核销」。油卡、话费、视频会员这类商品没有物流,下单后直接从卡密池取一条可用卡密,标记为已售,返回给用户。它的关键表是卡密表,字段通常包括卡号、卡密、面值、状态、批次号、绑定订单号。状态流转是:未使用、锁定中、已使用、已作废。
聚合支付的核心是「多通道统一收单」。同一笔订单可能走微信、支付宝、云闪付,系统要按路由规则选通道,统一封装请求,统一处理异步通知,统一对账。它的关键不是支付本身,而是通道适配层和回调幂等。
三条线共用的底座是:用户表、账户表、订单表、流水表、回调日志表。看懂这五张表的关系,源码就通了一半。
2.2 订单状态机与幂等设计
支付类系统最怕的不是并发高,而是状态乱。同一笔订单被回调两次、被重复发货、被人工改状态,都是血泪经验。
一个可靠的订单状态机应该满足三点:状态只能单向流转、每次流转写流水、外部回调必须幂等。下面是一个简化的状态定义,用 PHP 数组表达,Java 或 Python 思路一致。
<?php // 订单状态常量:只允许按箭头方向流转 // 待支付 -> 支付中 -> 已支付 -> 已发货 -> 已完成 // 任意状态 -> 已关闭(仅未支付时允许) const ORDER_STATUS = [ 'PENDING' => 0, // 待支付 'PAYING' => 1, // 支付中,已请求通道 'PAID' => 2, // 通道确认收款 'DELIVERED' => 3, // 卡密已发放或代付已确认 'FINISHED' => 4, // 终态 'CLOSED' => 9, // 关闭 ]; // 合法流转表:key 是当前状态,value 是允许到达的状态 const STATUS_FLOW = [ ORDER_STATUS['PENDING'] => [ORDER_STATUS['PAYING'], ORDER_STATUS['CLOSED']], ORDER_STATUS['PAYING'] => [ORDER_STATUS['PAID'], ORDER_STATUS['CLOSED']], ORDER_STATUS['PAID'] => [ORDER_STATUS['DELIVERED']], ORDER_STATUS['DELIVERED'] => [ORDER_STATUS['FINISHED']], ];这段代码的逻辑是:任何状态变更前先查STATUS_FLOW,不在允许列表里就直接拒绝。参数说明:PENDING是订单刚创建,PAYING是已经向支付通道发起请求但还没收到确认,这两个状态之间必须有一次通道请求记录,否则会出现「没请求却变成支付中」的脏数据。
幂等的实现通常靠一张唯一索引表。回调进来时,用「通道号 + 通道订单号」做唯一键插入回调日志,插入成功才继续处理业务,插入冲突说明已经处理过,直接返回成功。这个做法比在订单表上加锁更轻,也更适合聚合支付多通道场景。
提示:状态机不要用字符串直接比较,统一用整型常量,避免「paid」和「PAID」这种玄学问题。
2.3 卡密池的锁定与释放
油卡卡密系统最容易翻车的地方是超卖。两个订单同时取到同一条卡密,或者卡密被锁定后订单关闭却没释放。
常见做法是「先锁定再发放」。取卡密时用一条带条件的更新语句,把状态从「未使用」改成「锁定中」,同时写入绑定订单号。如果更新影响行数为 0,说明被别人抢了,换下一条。
-- 锁定一条可用卡密,LIMIT 1 配合状态条件避免超卖 UPDATE card_secret SET status = 'LOCKED', order_no = :order_no, lock_time = NOW() WHERE status = 'UNUSED' AND batch_id = :batch_id ORDER BY id ASC LIMIT 1;参数说明:batch_id用于按批次取卡,方便后续对账;order_no写入后,释放时才能精确回滚。执行完这条语句后,再查一次order_no = :order_no AND status = 'LOCKED'确认拿到的是哪条卡密。如果订单在支付超时后关闭,必须有定时任务把LOCKED且超过 15 分钟的记录改回UNUSED,否则卡密池会越用越少。
这里有个细节:锁定和发放要分开。支付成功后才把LOCKED改成USED,支付失败或超时则改回UNUSED。不要一锁定就直接标记已使用,否则用户没付款卡密就废了。
3. 聚合支付通道适配层怎么写才不乱
3.1 统一支付接口的参数设计
聚合支付源码里,通道适配层是最值得细看的部分。如果每个通道都写一套 if-else,后面加通道会痛不欲生。正确做法是定义一个统一接口,每个通道实现自己的签名、请求、验签、解析。
统一接口的输入参数一般包括:商户订单号、金额(分)、通道编码、异步通知地址、同步跳转地址、附加参数。输出统一为:是否成功发起、通道订单号、请求报文、跳转链接或二维码内容。
下面是一个 PHP 接口定义,用抽象类表达。
<?php abstract class PayChannel { // 发起支付,返回统一结构 abstract public function pay(array $order): array; // 验证异步通知,返回统一结构 abstract public function verifyNotify(array $post): array; // 查询订单,用于对账补偿 abstract public function query(string $orderNo): array; } // 统一返回结构约定 // pay 返回:['ok'=>bool, 'channel_no'=>string, 'payload'=>string] // verifyNotify 返回:['ok'=>bool, 'order_no'=>string, 'amount'=>int, 'channel_no'=>string]逻辑说明:pay负责组装通道要求的参数并签名,payload可能是跳转 URL 或二维码字符串。verifyNotify必须做验签和金额比对,金额不一致直接拒绝。query用于定时对账,防止异步通知丢失导致订单一直停在PAYING。
参数说明:金额统一用「分」为单位,避免浮点误差;order_no是商户侧唯一订单号,通道侧订单号单独存字段,两者不要混用。
3.2 异步回调的验签与防重放
回调是支付系统里最容易被攻击的入口。常见问题有三个:不验签、不校验金额、不防重放。
验签的逻辑是:按通道规则把参数排序拼接,加上商户密钥,做 MD5 或 RSA 验证。不同通道规则不同,但都要在适配层里实现。防重放则靠前面提到的回调日志唯一索引。
<?php // 回调处理伪代码:先验签,再幂等,再改状态 public function notify($channelCode) { $channel = ChannelFactory::make($channelCode); $result = $channel->verifyNotify($_POST); if (!$result['ok']) { exit('sign error'); // 验签失败直接拒绝 } // 幂等:通道号 + 通道订单号 唯一 $inserted = CallbackLog::insertIgnore([ 'channel' => $channelCode, 'channel_no' => $result['channel_no'], 'raw' => json_encode($_POST), ]); if (!$inserted) { exit('success'); // 已处理过,直接返回成功 } // 金额比对:回调金额必须等于订单金额 $order = Order::findByNo($result['order_no']); if ($order['amount'] != $result['amount']) { exit('amount error'); } OrderService::markPaid($order['id'], $result['channel_no']); exit('success'); }逻辑说明:先验签,再插回调日志,插入失败说明重复通知,直接返回成功让通道停止重试。金额比对是防止篡改。最后才改订单状态。参数说明:insertIgnore依赖唯一索引,MySQL 里用INSERT IGNORE或ON DUPLICATE KEY UPDATE都可以,但不要用「先查再插」,并发下会漏。
注意:回调接口必须返回通道要求的成功标识,通常是纯文本
success,不要返回 JSON,否则通道会一直重试。
3.3 对账与补单任务
异步通知会丢,这是常态。所以聚合支付系统必须有对账任务。常见做法是每天凌晨拉取通道对账单,和本地流水逐笔比对,发现「通道已成功、本地未成功」的订单就补单。
补单不是直接改状态,而是走和回调一样的markPaid逻辑,保证幂等。对账结果要落表,记录差异类型:本地多、通道多、金额不一致、状态不一致。差异单需要人工介入,不要自动改金额。
# 定时任务示例:每天 02:00 执行对账 0 2 * * * /usr/bin/php /www/pay/cron/reconcile.php --channel=wechat >> /var/log/reconcile.log 2>&1参数说明:--channel指定通道,避免一次拉太多;日志单独落文件,方便排查。对账脚本要加锁,防止上一次没跑完下一次又启动。
4. 代付与卡密发货链路的落地细节
4.1 代付订单的认领与超时释放
代付和普通支付最大的区别是「认领」。订单创建后处于待认领,付款方通过口令或链接认领,认领后进入待支付。如果 30 分钟内没人认领,订单自动关闭。
认领动作要加锁,防止两个人同时认领同一笔订单。常见做法是用 Redis 分布式锁,key 是订单号,过期时间略大于业务处理时间。
<?php $lockKey = 'daifu:claim:' . $orderNo; $locked = Redis::set($lockKey, $uid, ['nx', 'ex' => 10]); if (!$locked) { return ['ok' => false, 'msg' => '订单已被认领']; } // 再次查库确认状态仍是待认领 $order = Order::findByNo($orderNo); if ($order['status'] != ORDER_STATUS['PENDING']) { Redis::del($lockKey); return ['ok' => false, 'msg' => '状态不允许认领']; } OrderService::claim($orderNo, $uid); Redis::del($lockKey);逻辑说明:先抢锁,再查状态,两个动作缺一不可。只抢锁不查状态,可能出现锁释放后状态已变;只查状态不抢锁,并发下两个请求都查到待认领。参数说明:ex => 10是锁自动过期,防止死锁;业务处理完主动删除锁。
超时释放用定时任务扫描PENDING且创建时间超过 30 分钟的订单,改成CLOSED。如果订单已经进入PAYING,不要直接关闭,要先查通道确认未支付再关。
4.2 卡密自动发货的触发时机
卡密发货的触发点只有一个:订单状态变成PAID。不要在支付回调里直接发货,而是回调改状态后,由状态变更事件触发发货。这样补单、人工改状态也能触发发货,逻辑统一。
发货流程是:锁定卡密、标记订单已发货、把卡密内容写入发货记录、返回给用户。如果锁定失败(卡密池空了),订单进入「待补货」状态,通知运营。
<?php // 状态变更为 PAID 后触发 public function onPaid($orderId) { $order = Order::find($orderId); if ($order['type'] != 'CARD') { return; // 非卡密订单不处理 } $card = CardService::lockOne($order['batch_id'], $order['order_no']); if (!$card) { OrderService::markWaitStock($orderId); return; } CardService::markUsed($card['id'], $orderId); Delivery::write($orderId, $card['card_no'], $card['card_secret']); OrderService::markDelivered($orderId); }逻辑说明:lockOne返回空说明没库存,标记待补货而不是失败,方便后续补发。markUsed和write要在一个事务里,避免卡密标记已用但发货记录没写。参数说明:batch_id来自订单创建时选定的批次,不要发货时再随机选批次,否则对账对不上。
4.3 发货失败的重试与人工介入
发货失败分两种:暂时失败(数据库超时)和永久失败(卡密池空)。暂时失败可以重试三次,永久失败必须人工。
重试要用队列,不要在主流程里 sleep 重试。队列消费失败后写死信表,运营在后台看到死信可以手动补发。补发时同样走onPaid逻辑,保证幂等。
提示:发货记录表要有唯一索引
order_id,防止同一订单重复发货。这是最后一道防线。
5. 这类源码部署与二次开发的避坑清单
5.1 环境与依赖的常见翻车点
第一个坑是 PHP 版本。很多老源码写的是 PHP 5.6 风格,放到 PHP 8 上会报一堆废弃警告,尤其是each()、mysql_*函数。解决方式是先看composer.json或入口文件的版本声明,没有声明就本地用 PHP 7.4 跑,再逐步升级。
第二个坑是扩展缺失。支付类源码常依赖bcmath、openssl、redis、gd。部署前用php -m对一遍,缺哪个装哪个。RSA 验签失败很多时候不是代码问题,是openssl没开。
第三个坑是时区。订单时间、回调时间、对账时间必须统一时区,否则对账时会出现「本地时间比通道时间早 8 小时」的差异。在入口文件里显式设置date_default_timezone_set('Asia/Shanghai')。
5.2 数据库与缓存的配置陷阱
数据库方面,订单表和流水表一定要建联合索引,常见查询是「用户 + 时间」「状态 + 时间」。没有索引时,订单量到十万级就会明显变慢。
缓存方面,不要把订单状态只放 Redis。Redis 重启或过期后,状态就丢了。正确做法是数据库为准,Redis 只做锁和热点缓存。锁的过期时间要大于业务最长处理时间,否则会出现「业务没处理完锁先过期」的并发问题。
还有一个隐蔽的坑:MySQL 的utf8和utf8mb4。卡密内容里如果有特殊字符,utf8存不进去会截断。建库时直接用utf8mb4。
5.3 安全与合规的边界
源码里常见的危险写法包括:回调不验签、金额从客户端传、SQL 拼接、后台弱口令。二次开发时,回调必须服务端验签,金额必须从数据库读,SQL 用预处理,后台强制改默认密码。
另外,代付和卡密业务涉及资金和虚拟商品,上线前要确认业务本身合法合规,不要用来做灰色场景。源码只是工具,怎么用取决于人。
5.4 日志与排查手段
支付系统没有日志等于黑匣子。必须记录:请求日志(通道、参数、响应)、回调日志(原始报文、验签结果、处理结果)、状态变更日志(订单号、旧状态、新状态、操作人)。
排查订单问题时,按「订单号 → 流水 → 回调日志 → 通道请求日志」的顺序查,基本能定位到是哪一步断了。如果通道请求日志没有,说明请求根本没发出去;如果有请求没回调,去通道后台查订单状态。
注意:日志里不要打印完整卡密和密钥,脱敏后再落盘。
6. 用一笔测试订单验证整条链路
部署完别急着接真实通道,先用沙箱或 mock 通道跑一笔完整订单。我的习惯是写一个测试脚本,模拟「创建订单 → 发起支付 → 模拟回调 → 触发发货 → 查发货记录」五步,每一步断言状态。
<?php // 链路自测脚本:不依赖真实通道 $orderNo = 'TEST' . time(); OrderService::create(['order_no' => $orderNo, 'amount' => 100, 'type' => 'CARD']); // 模拟通道回调 $_POST = [ 'order_no' => $orderNo, 'amount' => 100, 'channel_no' => 'MOCK123', 'sign' => md5($orderNo . '100' . 'MOCK123' . MOCK_KEY), ]; $channel = new MockChannel(); $result = $channel->verifyNotify($_POST); assert($result['ok'] === true); OrderService::markPaid($orderNo, 'MOCK123'); $order = Order::findByNo($orderNo); assert($order['status'] == ORDER_STATUS['DELIVERED']); $delivery = Delivery::findByOrder($orderNo); assert(!empty($delivery['card_secret'])); echo "链路通过\n";逻辑说明:这个脚本把回调验签、状态流转、卡密发货串起来,任何一步失败都会断言中断。参数说明:MOCK_KEY是 mock 通道的密钥,真实环境换成通道密钥。跑通后再接真实通道,能省掉大量「到底是代码问题还是通道问题」的扯皮。
进阶一点的做法是把这笔测试订单的对账也跑一遍,确认对账任务不会把它当成差异单。如果对账脚本把测试单标成差异,说明对账的过滤条件没排除测试类型。
我自己的习惯是:每次改完支付相关代码,先跑这个脚本,再跑对账,最后才上真实环境。这个习惯帮我挡过好几次「回调验签改坏了」的事故。希望帮到你。
本文还有配套的精品资源,点击获取