简介:这套五合一代付系统源码采用前后端分离架构,前端基于 React.js、后端基于 Node.js,适合有 Node.js/React 基础的系统开发者、站长或二开工程师。整体以多平台代付业务为主线,内置美团、京东、拼多多、滴滴、携程五套独立前端模板,各模板均配置专属标题与全新 UI 界面,一比一还原主流平台风格,并完整打通下单、发货、收货闭环,附带的商城系统、后台管理系统与自动化脚本可支撑演示或二次改造。压缩包共 89 个文件,以 js 逻辑文件、tsx 组件文件为主,另有 json 配置、svg 图标、ts 类型定义、md 说明等,体积仅 595KB,目录结构清晰,方便按模块检索与修改。目前已有 911 人学习浏览,源码全开源无加密,既可直接部署,也能自由调整模板与后端逻辑,适合需要多平台整合、完整业务链路参考的开发者。
1. 美团代付五合一源码系统:一套能同时管五条代付通道的后台
这套代付系统源码不是那种只给一个下单接口的小demo,解开后拿到的是一套能直接部署的PHP后台:订单创建、平台路由、状态机、回调验签、定时查单、每日对账全都带,适配了美团外卖、京东、拼多多、携程四个主流通道,另外留了一个通用适配器位,这就是“五合一”的由来。它的价值在于把代付业务里最容易出问题的“异步回调”和“重复通知”处理成了体系化代码,而不是靠人工去平台后台一个个核对订单。适合做代购代付、团队采购、聚合收单场景的开发者或小团队使用,也适合想研究支付状态机和回调机制的PHP后端。
我拆这套源码时最直观的感受是:作者显然被“用户付了款但平台没回调”这种事坑过,因为主动查单和冻结单的代码占了很大比重。接下来我会从状态机开始,把部署、配置和避坑点逐个说透。
2. 代付单状态机与并发回调:先搞清楚钱在哪边记账
在拆这种支付后台源码时,我第一件事永远是看状态机,而不是先点页面。代付业务本质上就是两套账:内部订单记一笔,外部平台记一笔。两边对不上,轻则出冻结单,重则重复入账。所以看代码前,先要把流程和状态定义说清楚。
2.1 通用代付流程:创建、发起、回调、查单四段式
从这套源码里还原出来的核心流程是下面八步:
- 用户在代付后台创建代付单,填写目标平台订单号和实付金额。
- 系统校验商户余额或信用额度,通过后生成内部订单号,状态置为
pending。 - 系统按平台路由调用对应适配器,请求外部平台的代付接口。
- 外部平台返回预支付标识,系统把订单更新为
paying。 - 用户跳转完成付款。
- 外部平台异步回调代付后台,后台验签后把订单置为
paid。 - 如果回调长时间没到,定时任务调用平台查询接口补充确认状态。
- 每天凌晨跑对账,对比内部流水和外部平台订单金额。
最容易被误解的是第6步。很多第一次做支付系统的同学觉得“用户付钱成功”就等于“代付成功”,实际不是。用户只是把钱付给了代付平台,外部平台那个订单是否入账,必须要等回调或主动查单确认。这套源码把用户支付回调和平台入账回调拆开处理,是我认为它值得看的原因之一。
状态机核心定义一般写成这样:
class OrderStateMachine { const STATE_PENDING = 'pending'; const STATE_PAYING = 'paying'; const STATE_PAID = 'paid'; const STATE_FAILED = 'failed'; const STATE_FROZEN = 'frozen'; private $allowedTransitions = [ self::STATE_PENDING => [self::STATE_PAYING, self::STATE_FAILED], self::STATE_PAYING => [self::STATE_PAID, self::STATE_FAILED, self::STATE_FROZEN], self::STATE_PAID => [], self::STATE_FROZEN => [self::STATE_PAID, self::STATE_FAILED], ]; public function canTransition(string $from, string $to): bool { return in_array($to, $this->allowedTransitions[$from] ?? [], true); } }逻辑说明:状态迁移全部收口到canTransition()方法里,其他地方不允许直接写死状态字符串。好处是后续加退款状态、人工补单状态时,只需在这一张表里加迁移路径,不用翻遍整个项目找UPDATE orders。
参数说明:STATE_FROZEN是这份源码里最特殊的状态。它不代表失败,而是代表“外部平台返回了不确定结果,需要人工或定时任务确认”。比如回调超时、平台返回系统繁忙,都会先进frozen,再由查单任务决定转paid还是failed。这个设计的好处是不会把异常单粗暴标记为失败后钱货两不清。
2.2 并发回调与乐观锁:同一订单被回调两次怎么办
代付系统上线后,遇到最多的回调问题不是“没回调”,而是“回调了两次”。平台侧为了可靠性会重试通知,同一笔订单的回调可能几分钟内重复到达。如果回调处理代码是简化到一行UPDATE orders SET status='paid' WHERE order_no='xxx',那第二次回调大概率会把第一次的流水覆盖掉,甚至把一笔已退款订单重新改回paid。
这套源码用的是版本号方案,我把它简化成下面这段:
public function updateStatusWithVersion( int $orderId, string $newStatus, int $expectVersion ): bool { $sql = "UPDATE orders SET status = :new_status, version = version + 1 WHERE id = :id AND version = :expect_version"; $stmt = $this->db->prepare($sql); $stmt->execute([ 'new_status' => $newStatus, 'id' => $orderId, 'expect_version' => $expectVersion, ]); return $stmt->rowCount() === 1; }逻辑说明:把“检查版本号”和“更新状态”合并成一条UPDATE ... WHERE version = ?的原子SQL,谁先执行谁成功,后到的请求rowCount()返回0,说明状态已经被改过,直接丢进重复回调分支。
参数说明:expectVersion必须来自读取订单时的快照,不能从前端页面缓存里拿。后端常见的替代方案是SELECT ... FOR UPDATE加事务,但版本号方案能省掉行锁等待,在高并发回调场景下吞吐量明显更高。
2.3 平台适配器差异:美团、京东、拼多多、携程谁最容易坑
“五合一”的难点不在内部状态机,而在外部平台适配。这套源码每个平台一个类,统一实现createOrder()、verify()、query()三个方法,再由路由类决定走哪个类:
class PlatformRouter { private array $adapters = [ 'meituan' => MeituanAdapter::class, 'jd' => JdAdapter::class, 'pdd' => PddAdapter::class, 'ctrip' => CtripAdapter::class, ]; public function route(string $platform): PlatformAdapterInterface { $class = $this->adapters[$platform] ?? GenericAdapter::class; return new $class(); } }路由类的作用只有一个:把配置里的platform字符串翻译成具体适配器实例。默认落到GenericAdapter,对应我前面说的第五通道预留位。
我实际跑过之后的体感是这样:
- 美团外卖回调字段多,签名串里夹杂换行符,处理前必须统一
trim()和urldecode。 - 京东老版本接口金额单位是元,新版本改成返回分,适配器内部要做单位归一化。
- 拼多多回调最慢,极端情况延迟到20分钟,定时查单任务必须设得勤一点。
- 携程订单金额经常被优惠券改掉,不能直接信下单时的价格,要以支付结果里的实付为准。
这些差异在源码注释里标得比较清楚。我第一次部署时没细看,在京东适配器上栽了个跟头:所有单子金额差两个零。后来把分转元那部分代码仔细过了一遍,才意识到是京东新版字段返回值变动引起的。
3. 把源码跑起来:环境要求、数据库脚本与参数配置
部署代付系统源码,我的习惯是“先搭环境,再导数据库,最后配定时任务”。顺序不能颠倒,否则你会对着空白页面不知道错在哪一层。
3.1 环境清单:LNMP组合与PHP版本选择
源码没有用重量级框架,目录结构是原生PHP加轻量自研路由,所以对运行环境要求很普通。我推荐的组合是Linux + Nginx + PHP 7.4以上 + MySQL 5.7/8.0 + Redis 5.0。
| 组件 | 版本建议 | 说明 |
|---|---|---|
| Linux | CentOS 7.9 或 Ubuntu 20.04 | CentOS的yum源装PHP扩展更省事 |
| PHP | 7.4以上,建议8.0 | 需要openssl、pdo_mysql、redis扩展 |
| Nginx | 1.18以上 | 项目根目录指向public/ |
| MySQL | 8.0优先 | 字符集必须utf8mb4,否则表情符号写入报错 |
| Redis | 5.0以上 | 存幂等键、锁、任务队列 |
这套环境清单的选型理由是:原生PHP项目没有框架级依赖,但回调验签和数据读写都依赖扩展,openssl用于验签,redis用于幂等控制,pdo_mysql是基本操作。少了任何一个扩展,后台页面会直接白屏或报“类不存在”。
Nginx伪静态规则是第一个容易踩坑的地方:
location / { try_files $uri $uri/ /index.php$is_args$args; }这里的try_files意思是:如果请求的物理文件不存在,就把路径交给index.php处理。很多代付系统自带的路由是index.php?r=xxx,如果你配成/index.php/$1那种Rewrite方式,会导致一部分回调URL带上重复前缀,验签时签名串永远对不上。
3.2 数据库初始化:先建库,再导入install.sql
源码压缩包里通常会有install.sql,不要直接双击导入,先在命令行里建好库和账号,再执行脚本:
mysql -uroot -p -e " CREATE DATABASE IF NOT EXISTS daifu_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER IF NOT EXISTS 'daifu_user'@'localhost' IDENTIFIED BY '替换成强密码'; GRANT ALL PRIVILEGES ON daifu_system.* TO 'daifu_user'@'localhost'; FLUSH PRIVILEGES; "建完库之后导表结构。我习惯加--force参数,这样其中一张表建失败时不会让整个脚本中断,方便先看到哪里有问题:
mysql -udaifu_user -p daifu_system < install.sql --force导入完成后,重点看orders表的字段设计。这套源码里值得留意的字段:
| 字段 | 类型 | 关键说明 |
|---|---|---|
| order_no | varchar(32) | 内部订单号,唯一索引 |
| platform_order_no | varchar(64) | 外部平台返回的单号,回调查单全靠它 |
| platform | varchar(16) | 通道标识,meituan/jd/pdd/ctrip |
| amount | int | 金额单位分,禁止用float |
| status | tinyint | 关联状态机枚举 |
| callback_count | int | 累计回调次数,排查重复通知 |
| version | int | 乐观锁版本号 |
字段解读:callback_count是排查问题的利器。如果一笔订单这个值超过3,说明平台连续重试好几次,可能是因为回调处理器抛异常了。platform_order_no如果为空,说明下单请求在平台侧没有成功,不需要冻结单,直接走失败流程即可。
3.3 config配置文件:数据库、Redis和回调地址
数据库导入完成之后,把config.example.php复制为config.php,重点填这几项:
return [ 'db' => [ 'host' => '127.0.0.1', 'port' => 3306, 'name' => 'daifu_system', 'user' => 'daifu_user', 'pass' => '替换成强密码', 'charset' => 'utf8mb4', ], 'redis' => [ 'host' => '127.0.0.1', 'port' => 6379, 'timeout' => 2.0, ], 'app' => [ 'debug' => false, 'callback_url' => 'https://代付域名/api/callback', ], 'order' => [ 'expire_minutes' => 15, 'query_interval' => 2, 'callback_timeout' => 300, ], ];配置项说明:
expire_minutes是超时关单时间。我建议美团设15,拼多多设20,但这个值是全局的,想按平台区分需要在订单创建逻辑里加判断。query_interval是主动查单任务执行间隔,单位分钟。callback_timeout是回调幂等键的过期时间,单位秒。300秒意味着同一个订单回调5分钟内重复到达会被直接丢弃。
3.4 定时任务与常驻脚本
最后是关键一步:把定时任务写进crontab。这一步漏了,订单会一直停在paying状态,因为主动查单和超时关单都是靠脚本跑的:
# 关闭超时代付单 * * * * * /usr/bin/php /data/www/daifu/tasks/close_expired.php # 主动查单,补漏回调 */2 * * * * /usr/bin/php /data/www/daifu/tasks/query_paid.php # 每日对账 0 3 * * * /usr/bin/php /data/www/daifu/tasks/daily_reconcile.php # 清理过期日志 0 4 * * * /usr/bin/php /data/www/daifu/tasks/cleanup_logs.php这里有个参数细节:/usr/bin/php要写成绝对路径,因为crontab的环境变量很精简,php往往不在PATH里。你可以先用which php确认路径,再写进crontab。
query_paid.php脚本的核心查询条件通常是这样:
SELECT * FROM orders WHERE status = 'paying' AND updated_at < DATE_SUB(NOW(), INTERVAL 3 MINUTE) AND retry_count < 10查询条件里的3 MINUTE建议和config里的query_interval配合调整。retry_count < 10是防止某个单子查了几次都失败后无休止循环。
4. 对接五大平台通道:参数映射、回调验签与mock测试
代付系统源码能跑起来只是第一步,能不能真正收单,要看平台对接层写得够不够细。
4.1 平台参数映射:内部字段与外部字段的翻译表
每个平台的下单接口字段名都不一样,代付系统内部又必须统一用order_no、amount、platform这一套,所以中间会夹一个参数映射层。这套源码给每个平台单独定义一个映射类。
我整理过一张对照表,部署时可以直接照着检查:
| 内部参数 | 美团 | 京东 | 拼多多 | 携程 |
|---|---|---|---|---|
| 单号 | mt_order_id | jd_order_id | out_order_no | order_code |
| 金额(整数分) | total_fee | actual_fee | pay_amount | amount |
| 订单标题 | subject | title | order_subject | product_name |
| 失效时间 | 不传 | expire_time | 在此时间前失效 | 不传 |
映射表最忌讳原地改。平台接口升级时字段含义变了,比如京东把actual_fee从元改为分,直接改映射会导致老订单对账全乱。稳妥做法是给映射表加一列api_version,每个版本存一份映射记录,创建订单时带上当前版本号,后续排查对账差异也有据可查。
4.2 回调验签:先把sign算对,再谈业务逻辑
判断一份支付系统源码成不成熟,看验签函数就够了。这套源码里的验签函数长这样:
function verifySign(array $data, string $secret, string $sign): bool { unset($data['sign'], $data['sign_type']); ksort($data); $raw = urldecode(http_build_query($data)); $raw .= '&key=' . $secret; $calculated = md5($raw); return hash_equals($calculated, $sign); }逻辑说明:验签串由“排序后的参数拼接 + 密钥”组成。ksort按字母排序是为了保证拼接顺序和平台侧一致,这是MD5验签最常见的隐形坑——平台侧排序规则不一定是字母序,而是参数名字典序。hash_equals用于避免时序暴力探测,必须用。
参数说明:密钥$secret在各平台商户后台里叫法不同,有叫app_key的,有叫salt的。配置时别放错位置,拼多多把密钥叫pdd_private_key,配到config('platform.pdd.secret')里。
验签通过之后还有一道金额校验:
if ($callbackData['amount'] !== $order['amount']) { $this->markFrozen($order['id'], 'amount_mismatch'); return false; }这里不要用!=,要用!==。因为回调里的amount是字符串,数据库里的amount是整型,==会把字符串"1"和整数1当作相等,但真正的对比必须发生在两个值都转成整数之后。强制整型比较,才能拦住“差一分钱也回调成功”的脏数据。
4.3 mock支付驱动:不花一分钱走通全流程
源码在支付驱动上做了一个mock选项,我强烈建议测试阶段先不要换真实商户密钥,用mock把流程完整走一遍。配置方法:
'pay_driver' => 'mock', // platform 为真实支付 'mock_callback' => 'https://您的域名/api/callback/test'把pay_driver切到mock后,后台创建一笔代付单,然后用工具里的模拟回调页面触发通知。手动触发时重点验证三件事:
- 回调验签是否通过,如果失败先查排序规则。
- 订单状态从
paying正常走到paid。 - 平台适配器的
query()接口能被手动调用,把状态纠正到位。
mock跑一遍大概十分钟,却能省掉真实验签阶段一小时的排查。我第一次布置这套系统时跳过mock直接配真实参数,结果回调验签怎么都不过,最后发现是美团回调内容里多了一个htmlspecialchars转换,真实数据中的&被编码成了&,导致签名串错位。
5. 代付系统避坑指南:金额对不上、回调丢失和并发翻车
代付系统部署上线后的前两周,最常见的报警不是接口挂了,而是下面这四类问题。我按踩坑频次排一下。
5.1 金额精度问题:分转元与浮点数陷阱
现象:对账表里一笔订单差1分钱,后台看金额完全一致,数据库里却是两个数。
原因:创建订单时用了float类型,或者平台返回金额单位不统一。美团部分接口返回“分”,京东老版接口返回“元”,浮点数里0.1+0.2不等于0.3是经典问题。
解决:全链路用整数分存储,创建订单时统一转换:
function toCents(string $amount): int { [$integer, $decimal] = array_pad(explode('.', $amount), 2, '0'); return (int) $integer * 100 + (int) str_pad(substr($decimal, 0, 2), 2, '0'); }这里没有直接用round((float)$amount * 100),而是把金额当字符串拆开算,避免浮点精度干扰。还有一点:回调验签后的金额比对也要先统一转成整数分再比较。
5.2 回调丢失导致订单冻结
现象:用户截图说已经付款成功,但系统里订单卡在paying,超过配置时间后进入frozen。
原因:平台异步回调没有到达,最常见是回调URL配置成了内网地址,或者防火墙拦掉了平台所在IP段。
解决:先把回调URL放到公网能访问的位置,再调大查单频率。源码里的query_paid.php查单接口我习惯改成每2分钟一次,并让脚本把每次查单结果写进query_log表。如果一个单子连续5次查询都返回“处理中”,再转人工核查。
5.3 并发回调与乐观锁失效
现象:同一订单收到两次成功回调,系统入账两次。
原因:回调处理代码没用乐观锁,第二次回调直接把订单状态覆盖为paid,而第一次已经完成了入账流水。
解决:必须用前面2.2节的updateStatusWithVersion()写法,或者引入Redis锁。两个方案对比,我更推荐版本号方式,因为Redis锁在极端情况下会因锁过期导致并发穿透,版本号只依赖数据库ACID,链路更短。
5.4 大额代付被平台风控拦截
现象:小额测试全部正常,一到5000元以上就失败,平台侧显示风控拒绝。
原因:新商户号没有足够交易沉淀,大额交易会触发平台风险策略。
解决:运营层面分阶梯提升额度,先跑一周小额,每周逐步增加单笔上限。技术层面把失败原因原始报文记录到日志,不要只看平台返回的code,很多风控信息在sub_msg字段里。这里也必须说清楚:代付系统只适合真实业务场景的合规使用,金额异常频繁的单子,无论哪个平台都会盯上你。
6. 进阶用法:把五合一扩成六通道,并用对账看板掌控差异
系统稳定跑一个月后,你大概率会想加新平台。好在源码的适配器模式让扩展成本很低,关键是新增适配器时把三个方法补齐,再把对账脚本一并对上新通道。
6.1 新增平台适配器的标准写法
新适配器只需实现createOrder()、verify()、query()三个方法:
class NewPlatformAdapter implements PlatformAdapterInterface { public function createOrder(array $order): array { // 请求下单接口,返回 platform_order_no } public function verify(array $callbackData): bool { // 验签 + 金额校验 } public function query(string $platformOrderNo): array { // 主动查单 } }写完之后,在config.php的platform数组里加新平台的密钥配置,再到PlatformRouter的$adapters数组里补一行映射。最后把新平台加进daily_reconcile.php的对账平台列表,否则后台统计差异单时永远查不到它。
6.2 对账看板与回调审计日志
源码默认的对账脚本只生成差异表,不提供可视化页面。我改造的思路是加一个只读页面,读reconcile_diff表:
public function reconcileStats() { $stats = DB::query( "SELECT platform, status, COUNT(*) AS cnt FROM reconcile_diff GROUP BY platform, status" ); return view('reconcile_stats', ['stats' => $stats]); }这里按平台和状态两个维度分组,每天开工前看一眼,比翻数据库日志高效得多。另外我还把所有回调报文写进callback_log表,无论验签是否通过,字段包含order_no、platform、raw_data、verify_result、process_result。这张表平时不参与业务逻辑,只在出纠纷时拿来回溯原始报文。
从那以后,我每次给代付系统改代码都会强制走一遍固定流程:先跑mock回调单,再看次日对账报表,确认差异单数量归零,才允许提上线。这套动作看上去不酷,但它真的帮我截住过三次金额单位错误。希望这次拆解和这些坑位记录,能帮你在部署这套代付系统源码时少走几步弯路。
本文还有配套的精品资源,点击获取