简介:这份资源是一套基于PHP的淘宝/天猫代付系统源码与应用文档,适合有PHP后端开发基础、希望了解电商开放平台授权及支付回调机制的开发者。资源共2000个文件,以JS、HTML、CSS等前端文件为主,同时包含Markdown说明文档、JSON配置及SQL数据脚本,整体约75.19MB,覆盖从系统架构、OAuth2.0授权到订单管理和支付回调的完整实现思路。已有256人学习下载。内容对安全机制(数据加密、签名验证、防SQL注入与XSS)和开发流程做了详细梳理,阅读后可快速掌握代付系统的核心设计,并可直接参考其中的目录结构与前端交互代码进行二次开发。
1. PHP淘宝天猫代付系统:把代付流程从手动转账变成订单闭环
做代购或者企业采购的时候,最头疼的不是找货,而是付款这一环:订单在淘宝天猫生成,需要在指定时间点去付款,付完之后要核销,还要回填平台订单号。很多人用过的代付脚本,本质就是一个支付链接生成器,钱付了之后状态全靠人工改,漏一单就翻车。这套PHP淘宝天猫代付系统不是干这个的,它是一套围绕订单状态机的代付管理工具:从订单创建、支付发起、回调处理到日终对账,每一步都有对应的表和日志。适合想把代付流程做正规化、自动化,又有PHP基础的开发者或者小团队,拿回去改改数据库前缀就能跑。
2. 代付系统的核心逻辑:订单状态机与回调处理
2.1 订单五态模型:从创建到归档的状态流转
代付系统的核心不是支付,而是状态。一个代付订单从生到死,至少要经历五个状态:待支付(0)、支付中(1)、已支付(2)、已取消(3)、已退款(4)。
拿PHP实现来说,状态字段别用字符串乱写,我一般用 TINYINT 存状态码,0、1、2、3、4 对应上述五个状态。为什么要用数字不用字符串?一是索引更快,二是避免状态值在不同语言之间被翻译错。实际开发里,我看过有人把状态写成 "success"、"successed" 这样的字符串,后来统计的时候 SQL 写得很别扭。更离谱的是同一个系统里,一个模块写 "pending",另一个模块写 "wait_pay",对账的时候两边 JOIN 不上。
状态流转必须走统一的入口,不能每个控制器里直接 UPDATE。我习惯建一个 OrderStatusService,所有状态变更都通过它来执行,这样可以在状态变更前后插入钩子,比如记录日志、触发通知。否则状态变更散落在各处,排查问题的时候根本不知道这单是从哪里变过去的。
有人可能会问,状态机是不是过度设计?对于只有两三个订单量的脚本来说,确实是。但代付系统跑起来之后,一天几百单是很正常的,这时候状态机帮你在逻辑层挡住"从已退款跳回已支付"这类非法流转。一个 UPDATE 语句能改状态,但一个状态机服务能让状态改得有理有据。
提示:状态码一旦定下来,不要中途改数字含义。线上数据已经入库,改含义就要跑数据订正脚本,很容易弄出新旧不一致。
2.2 支付通道选型:预下单与幂等设计
代付系统的支付流程一般分两步:先创建支付单,再执行支付。这里有一个常见的坑,很多人把订单 ID 直接拿去做支付单号,一旦支付平台要求幂等,订单 ID 重试时就会撞单。正确做法是把订单 ID 拆开,代付系统内部用 order_id 管理业务,支付单号 pay_no 单独生成,格式类似 "PAY" + date + 6位随机数。这样订单业务和支付流水彻底解耦,后续退款、对账都拿 pay_no 去查,平台侧不会有任何歧义。
支付发起时,系统调用支付平台的预下单接口,拿到支付链接或者支付二维码。这一步要设置过期时间,我一般设 15 分钟,超时之后订单自动从待支付流转到已取消。不要设太长,否则占着额度不释放,采购人员会反复抱怨"我明明创建了订单却没额度了"。也不要设太短,用户从扫码到输入密码至少需要几分钟,5分钟以内基本会被切单。
选支付通道时,代付系统最常用的三套方案对比:
| 方案 | 适用场景 | 主要问题 |
|---|---|---|
| 支付宝当面付(预下单) | 系统生成二维码,人工扫码付款 | 需要企业资质,费率固定 |
| 微信Native支付 | 需要线下扫码 | 回调配置和证书管理麻烦 |
| 自有余额支付 | 内部账户资金池 | 需要处理充值、提现逻辑 |
我一般建议先接支付宝当面付,理由很简单:官方文档清晰、沙箱环境好、签名代码有官方SDK。微信支付在回调证书验证上多一层,适合项目稳定之后再迭代。至于自有余额支付,那是代付平台做大了之后的玩法,初期不要碰。接口对接时的 app_id、私钥、支付宝公钥这三样东西要放到独立配置文件里,不要写死在代码中,更不要提交到代码仓库。我在某公司就亲眼见过密钥躺在 Git 历史里,后来不得不重新生成一套。
2.3 回调处理:验签、幂等、失败重试
回调是整个代付系统最容易翻车的地方。原因很简单:支付平台的回调是异步的,可能延迟、可能重复、可能丢失。
第一层防御是验签。支付平台回传的数据包里通常带签名,拿平台的公钥或者密钥重新算一遍 MD5 或者 RSA 签名,对不上直接丢弃,别写入任何业务表。把验签失败的请求记到单独的日志表,方便追踪是不是有恶意请求在扫你的回调地址。实际项目里,我见过有人回调接口不做验签,上线当晚就被扫了十几万次,手续费消耗惨不忍睹。
第二层是幂等。同一个支付单号可能回调两次,第一次把订单从待支付改成已支付,第二次必须什么都不做直接返回成功。所以回调处理的第一步不是改订单,而是先查 payment_log 表里有没有这条回调记录,有就返回 success,没有才继续。
第三层是失败重试。回调处理中间环节如果出现异常(比如更新订单失败、清理缓存失败),必须让支付平台继续重试。所以回调接口的错误返回不能直接输出 500,应该明确告诉支付平台"我这次没处理好,你再等等"。很多新手在回调异常时直接 return 'fail',反而把问题搞糊涂了——支付平台过几分钟会再推一次,但此时本地日志里没有完整的异常堆栈,排查变成大海捞针。
回调还有一个容易被忽略的细节:回调请求的到达时间可能比用户付款时间晚很多。有可能用户昨天付款、今天才收到回调,如果处理逻辑里做了"只看当天的单子"这种限制,就会白白漏掉这笔更新。我在一个项目里看到过回调过滤条件里误写了 create_date = TODAY(),上线第二天早上全部订单状态都没变,原因就是用户跨天支付后回调落到了第二天。所以回调处理时,只要验签通过、订单存在,就不要对时间做额外过滤。
3. 落地实现:数据库设计与订单创建代码
3.1 核心表设计:订单、支付流水、回调日志分开存
我拆了这么多PHP项目之后,发现代付系统的表设计有个基本原则:订单表、支付流水表、回调日志表必须分开。原因很简单,一张表包打天下的后果是,回调重复了没法查,对账的时候也没有流水依据。
订单主表 df_order 至少包含这几个字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT UNSIGNED | 主键自增 |
| order_no | VARCHAR(32) | 业务订单号,唯一索引 |
| pay_no | VARCHAR(32) | 支付单号,唯一索引 |
| user_id | INT | 发起代付的用户ID |
| amount | BIGINT | 金额,单位分 |
| status | TINYINT | 0待支付 1支付中 2已支付 3已取消 4已退款 |
| channel | VARCHAR(16) | 支付渠道,如alipay/wechat |
| platform_order_id | VARCHAR(64) | 淘宝/天猫侧平台订单号 |
| create_time | DATETIME | 创建时间 |
| pay_time | DATETIME | 支付完成时间 |
金额必须用整数存分。PHP的浮点数在比较的时候经常出现 0.1+0.2 != 0.3 的情况,用分存就不会有这个问题。前端展示的时候再除以 100 转成元。platform_order_id 是业务侧字段,用来记录淘宝天猫那边的订单号,方便和平台侧对账。这个字段允许为空,因为有些代付场景在付款时还没有最终的平台订单号。
payment_log 表记录每一次支付相关的操作:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT UNSIGNED | 主键自增 |
| pay_no | VARCHAR(32) | 支付单号,索引 |
| action | VARCHAR(16) | 动作:create/pay/refund/notify |
| request_data | TEXT | 请求参数原文 |
| response_data | TEXT | 返回结果原文 |
| status | TINYINT | 处理结果:1成功 0失败 |
| create_time | DATETIME | 记录时间 |
这个表只做追加写入,不做更新。这样每次操作的历史都留底,即使某个时刻代码逻辑错了,数据也是完整的。排查问题的时候这就是第一入口:遇到"钱付了订单没变"这种问题,先查 payment_log,看最后一条 action 是什么、响应是什么,基本十分钟内能定位。
回调日志表 notify_log 单独用一个表存会更好。原因在于 payment_log 记录的是主动发起的请求,而 notify_log 记录的是被动接收的推送,两者的数据格式差异很大,混在一张表里会让查询变得复杂。notify_log 至少要有:pay_no、回调参数原文、验签结果、处理结果、到达时间。
建表的时候,df_order 表上 order_no 和 pay_no 必须建唯一索引,这是硬性要求。有没有唯一索引,决定了你在并发情况下会不会插入两条同样的订单。payment_log 表只需要在 pay_no 上建普通索引即可,因为查询总是以 pay_no 为维度,而且日志表只追加不更新,索引不会产生碎片问题。
3.2 创建代付订单:代码示例
下面这段是我一般会写的主流程,用 PDO 连接 MySQL,创建订单并生成支付单号。代码里用到了命名参数绑定,避免 SQL 注入,同时保证传入的字段类型正确:
<?php // 创建代付订单 public function createOrder(int $userId, int $amount, string $platformOrderId = ''): array { // 金额必须大于0,且以分为单位 if ($amount <= 0) { throw new InvalidArgumentException('金额必须大于0'); } $pdo = $this->getPdo(); $pdo->beginTransaction(); try { $orderNo = 'DF' . date('YmdHis') . mt_rand(1000, 9999); $payNo = 'PAY' . date('YmdHis') . mt_rand(10000, 99999); $stmt = $pdo->prepare( "INSERT INTO df_order (order_no, pay_no, user_id, amount, status, channel, platform_order_id, create_time) VALUES (:order_no, :pay_no, :user_id, :amount, 0, 'alipay', :platform_order_id, NOW())" ); $stmt->execute([ ':order_no' => $orderNo, ':pay_no' => $payNo, ':user_id' => $userId, ':amount' => $amount, ':platform_order_id' => $platformOrderId ]); $orderId = $pdo->lastInsertId(); // 支付流水必须在同一个事务里写入 $logStmt = $pdo->prepare( "INSERT INTO payment_log (pay_no, action, request_data, response_data, status, create_time) VALUES (:pay_no, 'create', :req, '{}', 1, NOW())" ); $logStmt->execute([ ':pay_no' => $payNo, ':req' => json_encode(['order_id' => $orderId, 'amount' => $amount]) ]); $pdo->commit(); return ['order_no' => $orderNo, 'pay_no' => $payNo, 'order_id' => $orderId]; } catch (Throwable $e) { $pdo->rollBack(); throw $e; } }这段代码里有几个关键点。一是订单号和支付单号分开生成,避免后续回调时两者混淆。订单号带 "DF" 前缀用于和后台人工沟通,支付单号带 "PAY" 前缀用于接口对接,两边视觉上立刻能分出是业务侧还是接口侧。二是整个创建过程放在事务里,订单和支付流水要么同时写入成功,要么同时回滚。三是金额一开始就用分,不接受元到分转换逻辑写在业务里,从源头排除浮点误差。
事务在这里还有一个容易被忽略的作用:如果 INSERT 订单成功但 INSERT 流水失败,事务回滚后订单号虽然已经生成了但不会入库,因此不会出现"有订单没流水"的脏数据。很多人在这一步贪图省事不加事务,后面对账的时候几乎必然出现明细对不上的问题。
3.3 状态变更的边界约束
状态流转不能乱跳。比如待支付订单可以直接取消,但已支付订单不能直接取消,必须先走退款流程。我一般在 OrderStatusService 里放一个状态转移表:
private array $transitions = [ 0 => [1, 3], // 待支付 -> 支付中 / 已取消 1 => [2, 3], // 支付中 -> 已支付 / 已取消 2 => [4], // 已支付 -> 已退款 3 => [], // 已取消 -> 终态 4 => [], // 已退款 -> 终态 ];每次变更先查这张表,不允许的直接抛异常。这个约束看着简单,实际能挡掉很多低级错误,比如回调延迟到了之后,订单已经被人为取消了,这时候直接把状态改成已支付,对账就会对不上。有了边界约束,这种情况会明确报错,提醒你需要人工介入。
这里多说一句,状态转移表最好和代码注释同步维护。我见过有些项目状态转移逻辑改了两三版,注释还是最初的版本,后来接手的人看着注释根本不敢动。这块没太多玄学,就是一个习惯问题:改了代码就顺手把注释和转移表更新掉,省得三个月后自己看都头疼。
4. 核心模块实战:支付发起、回调处理与主动查询
4.1 发起支付请求:接入支付平台预下单
支付发起的逻辑是:拿着订单信息去支付平台创建一个预支付单,平台返回支付链接或者二维码内容。这里我用的是支付宝当面付的模式,替换成微信支付或者其它通道时,只是请求地址和签名方式不同,流程都一样。
<?php // 创建预支付单 public function prepay(string $payNo, int $amount, string $subject): string { $config = $this->getChannelConfig('alipay'); $params = [ 'app_id' => $config['app_id'], 'method' => 'alipay.trade.precreate', 'format' => 'JSON', 'charset' => 'utf-8', 'sign_type' => 'RSA2', 'timestamp' => date('Y-m-d H:i:s'), 'version' => '1.0', 'biz_content' => json_encode([ 'out_trade_no' => $payNo, 'total_amount' => number_format($amount / 100, 2, '.', ''), 'subject' => $subject, 'timeout_express' => '15m' ]) ]; $params['sign'] = $this->rsaSign($params, $config['private_key']); $result = $this->httpPost($config['gateway_url'], $params); if (!isset($result['qr_code'])) { throw new RuntimeException('预下单失败: ' . json_encode($result)); } return $result['qr_code']; }参数说明:out_trade_no 这里是 pay_no 而不是 order_no,支付平台那边用 pay_no 做外部订单号,平台回调时也是带 pay_no 回来,系统里再通过 pay_no 关联到 order_no。total_amount 必须是字符串形式传给支付平台,不要传浮点数,有些平台对浮点数格式校验很严格,传 10.0 会校验失败。timeout_express 设为 15m,表示 15 分钟后订单关闭,这个时间要和本地自动取消任务的阈值一致,否则会出现本地还没取消、平台侧已经关单的情况。
调用预下单之前,还要判断订单当前状态,只有待支付和支付中的订单才能发起。已取消或已支付的订单再去预下单会得到一堆奇怪的错误码,不如直接在入口挡掉。接口调用失败时,不要把完整请求参数打到错误日志里,尤其不要包含私钥或 sign 字段。我见过有人把整个 $params 数组直接 error_log 出去,私钥就跟着日志文件一起流转了。
4.2 异步回调处理:正确姿势
支付平台会往你配置的回调 URL 上发 POST 请求。回调处理的质量直接决定这个系统能不能上线。我见过很多第一次做支付系统的开发者,回调里直接 UPDATE 订单状态就返回 success,结果用户重复支付时状态错乱。下面是正确的回调处理框架:
<?php public function notify(): string { $data = $_POST; // 第一步:验签 if (!$this->verifySign($data, $this->getAlipayPublicKey())) { $this->log('notify_verify_fail', $data); return 'failure'; } $payNo = $data['out_trade_no']; $tradeStatus = $data['trade_status']; // 第二步:幂等检查 if ($this->isNotifyHandled($payNo)) { return 'success'; } // 第三步:查订单,判断当前状态 $order = $this->getOrderByPayNo($payNo); if (!$order) { $this->log('notify_order_not_found', $payNo); return 'failure'; } if ($tradeStatus === 'TRADE_SUCCESS') { if (in_array($order['status'], [0, 1], true)) { $pdo = $this->getPdo(); $pdo->beginTransaction(); try { $pdo->exec( "UPDATE df_order SET status = 2, pay_time = NOW() WHERE id = {$order['id']} AND status IN (0, 1)" ); $this->insertNotifyLog($payNo, $data, 'success'); $pdo->commit(); return 'success'; } catch (Throwable $e) { $pdo->rollBack(); return 'failure'; } } return 'success'; } return 'success'; }这里的核心技巧:UPDATE 语句里再带一次 status 条件,确保数据库层面对状态兜底。即使前一次查询看到的状态和 UPDATE 执行时的状态不一致,这一步也能避免把已取消的订单强行改成已支付。同时,插入回调日志和更新订单状态在同一个事务里,不会出现"订单已更新但日志没写"这种半成功状态。
支付宝的回调状态字段 trade_status 有 TRADE_SUCCESS 和 TRADE_FINISHED 两种,很多新手会把两个都写进判断逻辑。其实对于即时到账代付来说,只需要处理 TRADE_SUCCESS 就够了。TRADE_FINISHED 代表交易彻底完成无法退款,但此时 payment_log 里大概率已经有了 TRADE_SUCCESS 的记录,重复处理没有任何意义。硬加上去反而多一个代码分支,测试覆盖不到就成隐患。
4.3 主动查询:回调丢失的兜底方案
我在第 5 章会详细讲回调丢失的排查方法,但这里先给一个口诀:回调不解决所有问题,主动查询才兜底。实现上是一个 cron 定时任务,每 3 分钟跑一次,把状态仍为待支付的订单捞出来,逐个调用支付平台的交易查询接口。
<?php // 主动查询支付状态 public function queryPayingOrders(): void { $orders = $this->getPendingOrders(200); // 每次最多处理200单 foreach ($orders as $order) { $result = $this->queryTrade($order['pay_no']); if ($result['trade_status'] === 'TRADE_SUCCESS') { // 复用回调处理的逻辑 $this->processPaymentSuccess($order['pay_no']); } elseif ($result['trade_status'] === 'TRADE_CLOSED') { $this->processOrderCancel($order['id']); } } }这个主动查询逻辑本质上和回调处理做的是同一种判断,所以我把它们抽成共用的 processPaymentSuccess 方法。这样回调来了走这个方法,主动查询查出已支付也走这个方法,两边行为完全一致,不会出现一个更新日志、一个不更新的不一致情况。参数上每次只处理 200 单,避免任务执行时间过长占用数据库连接。
有一个细节:主动查询的频率要和支付平台接口的 QPS 限制匹配。代付系统单量大的时候,每 3 分钟查 200 单已经接近某些平台的免费限额,再加一台服务器就会超限。这时候要加一个简单的令牌桶控制,以 pay_no 为 key 做一次 Redis 计数器,每秒最多放行 5 次查询请求,超过的直接跳过本轮,下轮再查。
4.4 回调安全:白名单IP与独立入口
支付平台的回调地址是固定的,可以在应用层做两层防护。第一层是 IP 白名单,只允许支付平台官方网段的请求进来。第二层是接口限流,用一个简单的计数器,同一秒内超过 10 次回调直接拒绝。这两层不完全可靠(回调 IP 可能变化),但能在攻击者扫回调接口时拖慢他的速度,争取到定位时间。
还有一个细节,回调地址不要用普通控制器的路由,我习惯单独放在 /api/pay/notify 这种独立入口,跟管理后台完全隔离。这样即使管理后台的 session 校验出了漏洞,攻击者也无法直接拿到回调入口的请求权限。另外,回调接口的响应不要输出任何调试信息,只返回 success 或 failure 两个字符串,避免暴露内部逻辑。安全这块不复杂,难的是坚持做。我见过太多项目因为"反正流量小不会被攻击"就省掉了这些防护,最后被扫到的时候都在加班修数据。
5. 避坑指南:代付系统最容易翻车的五个问题
5.1 回调丢失:订单卡在待支付
现象:用户那边显示钱已经扣了,但系统订单一直是待支付,后台客服被反复催单。
原因:支付平台的异步回调不是百分百到达,网络抖动、回调地址被防火墙拦截、服务器响应超时都有可能导致回调不到达。另外很多人没有主动查询的逻辑,回调不来订单就永远卡着。
解决:在订单超过 3 分钟后,启动一个主动查询任务。拿 pay_no 去支付平台查订单状态,如果平台返回 TRADE_SUCCESS 而你本地还是待支付,就走一遍和回调相同的处理逻辑。这个任务每 3 分钟跑一次,能覆盖掉绝大部分回调丢失的场景,如果再配合 5.2 讲的对账脚本,基本能做到百分百闭环。
5.2 金额精度:浮点数比较翻车
现象:日志里记录金额 9.99,订单金额 10.00,订单状态变成了已支付,但金额差了一分钱,对账对不上。
原因:PHP 的 float 在运算和比较时存在精度误差,9.99 实际存储可能是 9.989999999。如果没有在入库前转成整数分,累计多了之后就会出现分账对不平。
解决:所有涉及金额的字段全部用 BIGINT 存分,PHP 侧用 int 类型运算。入参如果是元,第一时间用 round($amount * 100) 转成整数分,不要用 (int)($amount * 100) 强转,后者在 9.99 这种值上会转出 998。字符串格式化成元再传给支付平台时,用 number_format($cents / 100, 2, '.', ''),不要用 sprintf('%0.2f') 省事,后者在部分 PHP 版本下会受 locale 影响带出千分位逗号,支付平台校验直接拒绝。
5.3 并发重复支付:同一订单被支付两次
现象:用户连续快速点击支付按钮,支付平台那边出现了两笔相同 out_trade_no 的交易,其中一笔还成功了。
原因:创建预支付单的接口没有做幂等限制,每次点击都会带着同一个 pay_no 去支付平台创建交易。支付平台对同一个 out_trade_no 重复创建会报错,但在报错之前的并发窗口里,可能已经有一笔被支付成功。
解决:在创建预支付单之前加一个 Redis 锁,以 pay_no 为 key,setnx 命令获取锁,拿不到锁的直接返回"正在处理中"。锁的过期时间设置 5 秒,足够创建完预支付单。另外,下单接口上也做一层防重:同一个订单如果在 10 秒内收到两次相同请求,第二次直接返回第一次生成的二维码。这样即使用户手速快,也不会给支付平台发两遍同样的请求。
5.4 回调重复处理:日志乱序
现象:同一笔订单收到了两次回调,状态更新没问题,但 payment_log 里两条 notify 记录的顺序和实际回调到达顺序不一致。
原因:并发回调时,两个请求同时进来,先后执行顺序不确定,日志写入顺序和回调到达顺序错位。如果日志查询依赖 create_time 排序,会把先到后写和后到先写的记录看反,排查方向就偏了。
解决:日志表里加一个自增主键,不要依赖 create_time 排序。查询最新一条日志时记得 ORDER BY id DESC,而不是 create_time。create_time 的精度到秒,同一个秒内并发插入时会分不出先后。这个坑看似不起眼,但真赶上回调风暴的时候,日志顺序错乱会让整个排查过程变得非常煎熬。
5.5 退款状态被回调覆盖
现象:订单已经退款完成,服务器又收到一次延迟的支付成功回调,订单状态被重新改成了已支付。
原因:回调处理里没有对已退款状态做判断。虽然 UPDATE 语句的 WHERE 条件限制了 status IN (0, 1),但如果代码里先查了订单、拿到状态之后又被别的流程改了,这中间就存在竞态窗口。
解决:UPDATE 之后立即 SELECT 一次,确认 status 确实是 2 而不是被其它流程改掉。更好的做法是状态流转统一走 OrderStatusService,在业务层就杜绝从已退款跳回已支付。第 3 章那张状态转移表就是这个用途。单纯的数据库条件兜底能解决大部分问题,但状态机在业务语义层面直接禁止这种跳转,双保险更稳。
另外加一个第 6 条的补充:服务器时区问题我放到第 6 章讲,因为那属于部署规范而不是业务逻辑。
6. 进阶:日终对账、定时任务与部署检查
6.1 日终对账脚本:批量比对本地与平台状态
代付系统跑起来之后,每天必须做一件事:对账。我一般写一个 console 命令,每天凌晨 2 点跑一次,逻辑很简单:把昨天的订单全部拉出来,逐个去支付平台查询交易状态,然后和本地订单状态做比对。
<?php // 日终对账命令 public function dailyReconcile(string $date): array { $start = $date . ' 00:00:00'; $end = $date . ' 23:59:59'; $orders = $this->getOrdersByRange($start, $end); $mismatch = []; foreach ($orders as $order) { if (!in_array($order['status'], [0, 2], true)) { continue; } $remote = $this->queryPayPlatform($order['pay_no']); if ($remote['status'] === 'TRADE_SUCCESS' && $order['status'] === 0) { $mismatch[] = ['order' => $order['order_no'], 'type' => 'missing_notify']; } if ($remote['status'] === 'TRADE_CLOSED' && $order['status'] === 2) { $mismatch[] = ['order' => $order['order_no'], 'type' => 'fake_notify']; } } return $mismatch; }对账脚本本身不修数据,只输出差异列表。差异人工确认后再手动处理,因为自动修数据风险太高,说不定一个系统 bug 把不该处理的订单也处理了。这套思路比写一堆自动修复逻辑要稳得多。输出格式上,差异列表写到一个 CSV 文件里,每天早上发给运营,运营在后台人工确认。一开始我觉得这个环节很傻,明明脚本能自动修为什么还要人工确认,后来有一次脚本误把一笔已取消的订单判断成待支付,自动修了对账数据,结果采购那边库存彻底乱了,从那以后我再也不让对账脚本自动写库。
6.2 定时任务:用 Cron 驱动三件事
三个定时任务是我认为必做的:过期订单自动取消、支付状态主动查询、日终对账。Cron 配置大概是下面这样:
# 每分钟执行一次,处理支付超时的订单 * * * * * php /path/to/console cancel_timeout_orders # 每三分钟执行一次,主动查询支付中的订单状态 */3 * * * * php /path/to/console query_paying_orders # 每天凌晨2点执行日终对账 0 2 * * * php /path/to/console daily_reconcile --date=yesterdaycancel_timeout_orders 只处理创建超过 15 分钟的待支付订单,把它流转到已取消。这个阈值必须和 4.1 里传的 timeout_express 一致,否则会出现平台关单时间和本地取消时间不一致,对账时差出 15 分钟窗口。query_paying_orders 把状态停留在支付中的订单逐个查询,查询结果是 TRADE_SUCCESS 就更新为已支付,是 TRADE_CLOSED 就更新为已取消。这个任务能减少一半因为回调丢失导致的用户咨询。
执行定时任务时,注意 PHP CLI 和 PHP-FPM 的环境变量可能不同。有些配置在网页访问时正常,在 CLI 下却读不到,比如密钥文件的路径。我习惯在 console 入口文件顶部统一做一次环境检测,加载同一个配置文件,避免 cron 和 web 两套环境各跑各的。部署新服务器时,先用php /path/to/console --list跑一遍,确认所有任务都能加载,再放进 crontab。
6.3 部署检查清单:日志、时区与环境变量
代付系统比普通业务系统更需要日志,因为涉及资金。我一般会要求三个日志必须全:业务日志(每个订单状态变更前后都记录)、支付接口日志(请求和响应的完整报文)、异常日志(try catch 里打上调用栈)。日志级别不需要复杂,生产环境记录 info 和 error 两个级别就够了。
异常日志里一定要记录 pay_no 和 order_no 这两个关联字段,不能只丢一个 message。没有关联字段的异常日志,排查时要先做一轮全表扫描才能定位到具体单据,很浪费时间。我现在写日志的习惯是,第一个参数永远是当前处理的订单号或者支付单号,这个习惯是从某次线上事故养成的:当时异常日志里只有 "database error" 四个单词,完全没法定位,后来强制规范了参数顺序才解决。
部署之后,我建议把下面几项做成一条命令检查:PHP 时区、MySQL 连接时区、服务器系统时区三者是否一致。代付系统的验签和回调时间戳都依赖时间,任何一层的时区偏差都会导致验签失败或者对账错位。我遇到过最长的排查长达三个小时,最后发现是服务器时区差了 8 个小时,支付平台验签用的是 UTC,而业务代码里用了本地时间。从那以后我每接手一个代付项目,先检查服务器时区、PHP 默认时区、MySQL 连接时区三者是否一致,再开始看业务代码,这个小习惯至少省掉过三次通宵排查。希望帮到你。
本文还有配套的精品资源,点击获取