简介:这份PHP淘宝天猫代付系统资源面向有一定PHP基础的开发者与电商项目实践者,聚焦于解决用户间代付款场景下的授权、订单传递与支付回调等核心问题。资源包共2000个文件,以1407个js脚本、210个html页面、117个css样式、111个json配置为主,另含131个md说明文档、2个sql建表脚本及少量sh、xml等辅助文件,压缩包约75.19MB,整体结构接近一套可运行的前后端工程。内容围绕OAuth 2.0授权、RESTful接口设计、订单获取与代付确认、支付回调及订单管理等模块展开,并涉及数据加密、签名验证、权限控制与SQL注入、XSS防护等安全要点,同时覆盖跨域处理、缓存优化与异常机制等实践细节。目前已有255人学习,适合用于理解代付业务链路、参考接口分层与安全实现,或作为二次开发的代码底本。
1. 代付系统到底在解决什么问题
做过电商代付场景的开发者都清楚,这个需求的核心矛盾在于:付款方和收货方不是同一个人。传统下单流程里,谁买谁付钱,资金流和订单流是绑死的。但代付场景要求把这两条线拆开——A 发起订单,B 完成支付,系统还得保证 B 付的钱确实落到 A 的订单上,且双方都能查到状态。
PHP 淘宝天猫代付系统,本质上就是围绕这个拆解逻辑搭建的一套中间层服务。它要处理订单创建、代付链接生成、支付回调校验、状态同步、超时退款这几个关键环节。适合谁用?做私域电商工具的开发团队、需要给客户提供代下单能力的小型平台、以及想在自己系统里嵌入代付能力的后端工程师。
我见过不少团队一开始觉得“不就是生成个链接让人付钱”,结果上线后遇到回调重复、订单状态错乱、对账对不上。这篇笔记就把我从零搭这套系统时踩过的路讲清楚,从表结构设计到回调幂等,再到对账补偿,每一步都给可复现的方案。
2. 代付系统的数据模型与订单状态机设计
2.1 为什么不能直接复用普通订单表
普通订单表的结构是围绕“买家即付款人”设计的,字段里通常只有一个 user_id 和一个 pay_status。代付场景下,你需要区分四个角色:发起人、付款人、收款方(平台商户)、实际收货人。如果硬塞进原表,会出现字段语义混乱——pay_user_id 到底记谁?
我的做法是单独建一张代付主单表,再挂一张支付流水表。主单表管业务状态,流水表管资金状态,两者通过 order_no 关联但各自独立更新。这样即使支付回调延迟或重复,也不会污染业务状态。
-- 代付主单表:记录业务维度的订单信息 CREATE TABLE `df_order` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '代付单号,全局唯一', `initiator_uid` int unsigned NOT NULL COMMENT '发起人用户ID', `payer_uid` int unsigned DEFAULT NULL COMMENT '实际付款人ID,支付前为空', `goods_amount` decimal(10,2) NOT NULL COMMENT '商品金额', `pay_amount` decimal(10,2) NOT NULL COMMENT '应付金额,可能含运费', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消 5已退款', `expire_time` int unsigned NOT NULL COMMENT '支付过期时间戳', `created_at` int unsigned NOT NULL, `updated_at` int unsigned NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_initiator` (`initiator_uid`), KEY `idx_status_expire` (`status`,`expire_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 支付流水表:只记录资金变动,不做业务判断 CREATE TABLE `df_payment` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `trade_no` varchar(64) NOT NULL COMMENT '第三方支付流水号', `payer_uid` int unsigned NOT NULL, `amount` decimal(10,2) NOT NULL, `pay_channel` varchar(16) NOT NULL COMMENT '支付渠道标识', `pay_status` tinyint NOT NULL DEFAULT '0' COMMENT '0处理中 1成功 2失败', `callback_data` text COMMENT '原始回调报文,排查用', `created_at` int unsigned NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_trade_no` (`trade_no`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这两张表的关键设计点:df_order 的 status 只由业务逻辑驱动,df_payment 的 pay_status 只由支付回调驱动。两者之间通过一个异步任务做状态对齐,而不是在回调里直接改主单状态。这样做的好处是回调处理足够轻,即使回调里只写流水表,后续补偿逻辑也能把主单状态追平。
2.2 状态机怎么画才不出乱子
代付单的状态流转比普通订单多一个“付款人绑定”的环节。我画状态机时遵循一条原则:任何状态只能向前,不能回退,退款是唯一例外且必须走独立分支。
具体流转路径是:待支付 → 已支付 → 已发货 → 已完成。待支付超时后进入已取消,已支付后发起退款进入已退款。这里有个容易翻车的地方:付款人打开链接但没付,链接过期了,此时如果发起人重新生成链接,是复用原单还是新建?我的选择是复用原单但刷新 expire_time,因为订单号不变对账才清晰。
<?php // 状态流转校验:只允许合法迁移 class DfOrderStatus { // 定义允许的状态迁移映射 private const TRANSITIONS = [ 0 => [1, 4], // 待支付 -> 已支付 / 已取消 1 => [2, 5], // 已支付 -> 已发货 / 已退款 2 => [3], // 已发货 -> 已完成 3 => [], // 已完成终态 4 => [], // 已取消终态 5 => [], // 已退款终态 ]; public static function canTransit(int $from, int $to): bool { return in_array($to, self::TRANSITIONS[$from] ?? [], true); } // 带乐观锁的状态更新,防止并发覆盖 public static function updateStatus(PDO $pdo, string $orderNo, int $from, int $to): bool { if (!self::canTransit($from, $to)) { throw new RuntimeException("非法状态迁移: {$from} -> {$to}"); } $sql = "UPDATE df_order SET status = :to, updated_at = :now WHERE order_no = :no AND status = :from"; $stmt = $pdo->prepare($sql); $stmt->execute([':to' => $to, ':now' => time(), ':no' => $orderNo, ':from' => $from]); return $stmt->rowCount() > 0; // 返回false说明状态已被其他进程改过 } }这段代码里 TRANSITIONS 常量就是状态机的黑匣子,任何状态变更前先查表。updateStatus 里用 status = :from 做乐观锁条件,如果返回 rowCount 为 0,说明在读取和更新之间有其他进程改了状态,调用方需要重新读取再判断。这个细节在支付回调并发场景下能救命。
2.3 订单号生成策略与分库分表预留
订单号我不用自增 ID,也不用纯随机数。自增 ID 会暴露业务量,纯随机数在分库分表后容易冲突。我一般用“时间戳 + 用户ID后四位 + 随机数”拼一个 24 位字符串,既有时序性又带一点业务标识,排查问题时能直接从单号看出大概时间段。
<?php function generateOrderNo(int $uid): string { // 毫秒级时间戳保证时序,用户ID后四位便于排查,随机数防碰撞 $ts = sprintf('%.0f', microtime(true) * 1000); $uidPart = str_pad((string)($uid % 10000), 4, '0', STR_PAD_LEFT); $rand = str_pad((string)random_int(0, 9999), 4, '0', STR_PAD_LEFT); return $ts . $uidPart . $rand; }这个函数生成的单号长度在 21 到 24 位之间,取决于毫秒时间戳的位数。如果未来要分库分表,可以取单号里的 uidPart 作为分片键,保证同一用户的订单落在同一个库。注意 random_int 比 rand 慢但更安全,订单号场景下这点性能损耗可以忽略。
3. 支付回调的幂等处理与异步对账补偿
3.1 回调入口为什么要先写流水再改状态
支付平台的回调有个特点:它不保证只来一次。网络抖动、平台重试、甚至人为补发,都可能让同一个 trade_no 的回调到达多次。如果你在回调里直接改主单状态,第二次回调进来时主单已经是“已支付”,再改就可能触发重复发货。
我的处理流程是:回调入口只做三件事——验签、写流水、投递异步任务。验签保证请求来源可信,写流水用 trade_no 唯一索引兜底重复,异步任务负责后续的状态对齐和业务触发。
<?php // 回调入口:轻量化处理,只做验签和落流水 function handlePaymentCallback(array $params): string { // 1. 验签,失败直接返回fail让平台重试 if (!verifySign($params)) { return 'fail'; } $pdo = getPDO(); try { $pdo->beginTransaction(); // 2. 写流水,uk_trade_no 唯一索引保证重复回调会抛异常 $stmt = $pdo->prepare( "INSERT INTO df_payment (order_no, trade_no, payer_uid, amount, pay_channel, pay_status, callback_data, created_at) VALUES (:order_no, :trade_no, :payer_uid, :amount, :channel, 1, :raw, :now)" ); $stmt->execute([ ':order_no' => $params['out_trade_no'], ':trade_no' => $params['trade_no'], ':payer_uid' => $params['payer_uid'], ':amount' => $params['total_amount'], ':channel' => $params['channel'], ':raw' => json_encode($params, JSON_UNESCAPED_UNICODE), ':now' => time(), ]); $pdo->commit(); } catch (PDOException $e) { $pdo->rollBack(); // 唯一索引冲突说明是重复回调,直接返回success避免平台继续重试 if ($e->getCode() == 23000) { return 'success'; } return 'fail'; } // 3. 投递异步任务,不在这里做业务处理 pushToQueue('df_order_paid', ['order_no' => $params['out_trade_no']]); return 'success'; }这段代码的核心逻辑:用数据库唯一索引做幂等,比在应用层查一遍再插入更可靠,因为查和插之间有并发窗口。catch 到 23000 错误码(MySQL 唯一键冲突)时直接返回 success,告诉支付平台“我收到了,别再发了”。异步任务投递失败怎么办?队列要有持久化,投递失败时记录日志并触发告警,后续靠对账补偿。
3.2 异步任务里怎么安全地改主单状态
异步任务消费到 df_order_paid 消息后,要做的是把主单从“待支付”推到“已支付”。这里必须用前面提到的乐观锁更新,因为可能存在两个消息同时消费的情况(队列重投)。
<?php // 异步消费者:对齐主单状态 function consumeOrderPaid(array $msg): void { $orderNo = $msg['order_no']; $pdo = getPDO(); // 读取当前主单状态 $stmt = $pdo->prepare("SELECT status, pay_amount FROM df_order WHERE order_no = :no"); $stmt->execute([':no' => $orderNo]); $order = $stmt->fetch(PDO::FETCH_ASSOC); if (!$order) { logError("代付单不存在: {$orderNo}"); return; } // 已经是已支付或更后状态,说明消息重复,直接跳过 if ($order['status'] >= 1) { return; } // 乐观锁更新,失败说明有其他消费者先改了 $ok = DfOrderStatus::updateStatus($pdo, $orderNo, 0, 1); if (!$ok) { logInfo("状态已被其他进程更新,跳过: {$orderNo}"); return; } // 状态更新成功后再触发后续业务,比如通知发货 triggerDelivery($orderNo); }这里的关键判断是$order['status'] >= 1这个前置检查,它挡住了绝大多数重复消息。即使挡不住,后面的乐观锁更新也会兜底。triggerDelivery 放在状态更新成功之后,保证不会出现“没改状态却发了货”的情况。
3.3 对账补偿任务怎么写才不漏单
异步任务和回调都可能丢,所以必须有一个定时对账任务兜底。我的做法是每 5 分钟扫一次 df_payment 里 pay_status=1 但 df_order 里 status=0 的记录,这些就是“钱到了但状态没同步”的漏单。
<?php // 对账补偿:每5分钟执行一次 function reconcileOrders(): void { $pdo = getPDO(); // 找出已支付流水但主单仍待支付的记录 $sql = "SELECT p.order_no, p.trade_no FROM df_payment p INNER JOIN df_order o ON p.order_no = o.order_no WHERE p.pay_status = 1 AND o.status = 0 LIMIT 100"; $stmt = $pdo->query($sql); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as $row) { // 复用异步消费者的逻辑,保证处理路径一致 consumeOrderPaid(['order_no' => $row['order_no']]); logInfo("对账补偿处理: {$row['order_no']}"); } }这个任务每次只取 100 条,避免一次锁太多行。处理逻辑直接复用 consumeOrderPaid,保证补偿路径和正常路径行为一致。如果同一条记录连续多次对账都失败,说明有更深层的问题,需要人工介入,所以我在 logInfo 之外还会累计失败次数,超过阈值就发告警。
4. 代付链接的安全设计与防刷策略
4.1 链接里放什么参数才安全
代付链接的核心诉求是:付款人点开就能付,不需要登录发起人的账号。但链接如果只放 order_no,任何人拿到都能付,这本身没问题,问题在于链接可能被恶意传播或篡改金额。
我的方案是链接里放 order_no 和一个签名 token,token 由 order_no + 过期时间 + 密钥做 HMAC 生成。服务端收到请求后重新计算签名并比对,同时校验过期时间。这样即使有人改了链接里的参数,签名对不上直接拒绝。
<?php // 生成代付链接token function buildPayToken(string $orderNo, int $expireTime): string { $secret = getenv('DF_PAY_SECRET'); // 从环境变量读取,不硬编码 $raw = $orderNo . '|' . $expireTime; return hash_hmac('sha256', $raw, $secret); } // 校验token function verifyPayToken(string $orderNo, int $expireTime, string $token): bool { if (time() > $expireTime) { return false; // 已过期 } $expected = buildPayToken($orderNo, $expireTime); return hash_equals($expected, $token); // 时序安全比较 }hash_equals 是必须的,不能用 == 比较,否则会有时序攻击风险。密钥从环境变量读取,不写在代码里。过期时间建议设 30 分钟到 2 小时,太短付款人来不及操作,太长链接被传播的风险窗口就大。
4.2 防刷:同一订单的支付频率限制
代付链接可能被恶意用户拿到后反复发起支付请求,虽然最终只有一笔能成功,但大量无效请求会打满支付通道。我在支付发起接口加了一层 Redis 频率限制,同一个 order_no 每分钟最多允许 5 次支付请求。
<?php // 支付发起频率限制 function checkPayRateLimit(string $orderNo): bool { $redis = getRedis(); $key = "df:pay:rate:{$orderNo}"; $count = $redis->incr($key); if ($count === 1) { $redis->expire($key, 60); // 首次设置60秒窗口 } return $count <= 5; }这个逻辑用 Redis 的 INCR 原子操作实现,不会因为并发导致计数不准。窗口设 60 秒,阈值 5 次,正常用户完全够用。超过阈值的请求直接返回“操作过于频繁”,不进入后续支付流程。
4.3 付款人身份绑定与匿名支付的平衡
代付场景有个矛盾:付款人可能没有平台账号,但你又要记录是谁付的。我的做法是允许匿名支付,但支付成功后用付款时填的手机号或支付平台返回的 payer_id 做绑定。如果付款人后续注册了账号,可以通过手机号关联历史代付记录。
<?php // 支付成功后绑定付款人 function bindPayer(string $orderNo, ?int $uid, ?string $phone): void { $pdo = getPDO(); // uid 优先,没有 uid 时用手机号占位,后续可关联 $stmt = $pdo->prepare( "UPDATE df_order SET payer_uid = :uid, payer_phone = :phone, updated_at = :now WHERE order_no = :no AND payer_uid IS NULL" ); $stmt->execute([ ':uid' => $uid ?? 0, ':phone' => $phone ?? '', ':now' => time(), ':no' => $orderNo, ]); }payer_uid 为 0 表示匿名支付,payer_phone 存付款时填的手机号。后续如果该手机号注册了账号,可以用一个定时任务把历史订单的 payer_uid 补上。这个设计既不影响匿名付款体验,又保留了后续关联的可能性。
5. 避坑与常见问题排查
5.1 回调验签失败但平台显示已支付
现象:支付平台后台显示交易成功,但你的系统里订单还是待支付,日志里全是验签失败。
原因:最常见的是密钥不匹配。支付平台一般有两个密钥——一个用于发起支付,一个用于回调验签。有些平台的回调验签密钥和发起支付密钥是同一个,有些是分开的。我遇到过把发起密钥配到回调验签里的情况,结果所有回调都验签失败。
解决:先确认平台文档里回调验签用的是哪个密钥,然后在沙箱环境用平台提供的验签工具跑一遍。如果密钥确认无误,检查参数排序和编码——有些平台要求按参数名 ASCII 码排序后再拼接待签串,少一个参数或顺序错了都会导致验签失败。
5.2 重复回调导致重复发货
现象:同一个订单触发了两次发货流程,仓库收到两条发货指令。
原因:回调入口没有做幂等,或者异步消费者没有做状态前置检查。我见过一个实现是在回调里直接调发货接口,回调重复时发货也重复。
解决:回调入口只写流水,发货逻辑放在异步消费者里,消费者先查主单状态,已经是已支付或更后状态就直接返回。另外发货接口本身也要做幂等,用 order_no 作为发货单的唯一键,重复请求直接返回已发货。
5.3 订单超时取消后用户又支付成功
现象:订单 30 分钟超时被取消,但用户在 31 分钟时完成了支付,钱扣了但订单已取消。
原因:超时取消任务和支付回调之间存在竞态。取消任务扫到订单时还是待支付,执行取消;几乎同时支付回调到达,写入了流水。两边都成功,但状态冲突。
解决:取消任务执行前先查一次流水表,如果已经有成功流水就跳过取消。更稳妥的做法是取消和支付回调都走同一个状态机,用乐观锁更新,谁先抢到谁生效。如果取消先抢到,支付回调里的状态更新会失败,此时需要触发自动退款流程。
5.4 对账任务漏掉部分订单
现象:对账任务跑了,但有些漏单没被补偿。
原因:对账 SQL 的查询条件写窄了。比如只查了 pay_status=1 且 status=0 的记录,但有些订单可能 status=4(已取消)但流水是成功的,这些也需要退款处理,不在补偿范围内。
解决:对账任务要分两类——一类是状态未同步的(status=0 但有成功流水),一类是状态冲突的(status=4 但有成功流水,需要退款)。两类分开处理,退款类走独立的退款流程。另外对账 SQL 要加时间范围,只扫最近 24 小时的,避免全表扫描。
5.5 支付金额和订单金额不一致
现象:回调里的支付金额和订单应付金额对不上,但回调验签通过了。
原因:可能是用户改了支付金额(部分支付),也可能是平台回调里传的是实付金额而订单里存的是应付金额,两者在有优惠券时不一致。
解决:回调处理时先比对金额,不一致时不要直接改状态,而是记录异常并告警。如果平台支持部分支付,需要在业务层决定是否接受。我的做法是金额不一致时订单进入“待人工确认”状态,由运营介入处理,不自动发货。
6. 用对账报表验证系统健康度
系统上线后怎么判断它是否稳定?我的习惯是每天看三张报表:支付成功率、回调及时率、对账差异率。这三张表能覆盖 90% 以上的异常。
支付成功率 = 成功流水数 / 发起支付请求数。正常应该在 95% 以上,低于 90% 说明支付通道或链接生成有问题。回调及时率 = 5 分钟内收到回调的订单数 / 成功支付订单数。低于 98% 说明回调入口有性能瓶颈或网络问题。对账差异率 = 对账任务补偿的订单数 / 当日总订单数。高于 0.1% 说明异步链路有丢消息的情况。
-- 每日健康度报表 SELECT DATE(FROM_UNIXTIME(created_at)) AS day, COUNT(*) AS total_orders, SUM(CASE WHEN status >= 1 THEN 1 ELSE 0 END) AS paid_orders, ROUND(SUM(CASE WHEN status >= 1 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS pay_rate FROM df_order WHERE created_at >= UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY)) GROUP BY day ORDER BY day DESC;这个查询跑最近 7 天的数据,pay_rate 低于 90% 的日子需要重点排查。我一般会把这个查询结果推到内部监控面板上,每天早上扫一眼。如果某天数据异常,再下钻到小时级别看是哪个时间段出的问题。
还有一个我踩过的坑:对账任务本身也可能失败。所以对账任务要有心跳监控,超过 10 分钟没执行就告警。我现在的做法是对账任务每次执行完往 Redis 写一个时间戳,另一个监控任务检查这个时间戳是否超过阈值。
最后说一个习惯:每次上线新的支付渠道或修改回调逻辑后,我会手动构造一批异常回调——重复的、金额不对的、验签失败的——跑一遍看系统反应。这个后悔药比上线后出问题再回滚便宜得多。希望帮到你。
本文还有配套的精品资源,点击获取