ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP淘宝代付系统:RSA2签名、异步通知验签与状态闭环实现

PHP淘宝代付系统:RSA2签名、异步通知验签与状态闭环实现

简介:这是一套基于PHP开发的淘宝天猫代付系统实战项目,面向中高级Web开发者及电商支付场景技术实践者,解决多角色协同付款、授权支付集成与订单状态闭环管理等实际业务问题。资源包共2000个文件,涵盖1407个JavaScript交互逻辑文件、210个HTML前端页面、131个Markdown说明文档、117个CSS样式资源及111个JSON配置/接口数据,整体体积75.19MB;其中fastadmin、bootstrap与后台管理相关CSS文件密集,体现其具备完整前后端分离架构与可视化运营后台能力。已有258人学习下载,资源提供可直接部署的代付核心流程代码、OAuth 2.0授权接入示例、支付回调处理模块、SQL建表语句及安全防护(防SQL注入/XSS)实现片段,目录结构按功能分层清晰,便于理解代付系统从用户授权、订单同步、代付发起到状态更新的全链路设计逻辑。

1. 这不是电商后台,而是一套可落地的代付流程封装:PHP 实现淘宝/天猫订单资金中转与状态闭环

你手头有个 PHP 项目,需要对接淘宝或天猫的代付场景——比如用户在你的系统下单后,由你垫资完成对淘宝商家的付款,再向用户收款;或者你作为服务商,帮多个子商户统一处理天猫平台的代付结算。这不是简单的“调个 API 就完事”,而是涉及支付指令下发、异步通知验签、订单状态机同步、退款冲正、资金流水对账等一整套强一致性逻辑。市面上公开的 PHP 代付示例大多只贴几行 curl 调用,漏掉验签密钥管理、通知重试幂等、超时自动关单、回调地址白名单校验等生产级细节,导致上线后频繁出现“用户已付款但淘宝未到账”“退款成功但本地状态没更新”这类血泪翻车。本资源是一套经过某电商平台服务商真实压测验证的 PHP 代付系统骨架,不含任何 SDK 封装黑匣子,所有核心逻辑(含 RSA2 签名生成、AES-128-CBC 解密、支付宝开放平台通知解析、淘宝开放平台代付接口适配)全部展开为可调试、可断点、可替换加密库的原生 PHP 代码,适合中高级 PHP 工程师快速集成进自有订单中心,也适合想吃透电商代付链路的新手逐行跟读。


2. 代付系统架构选型:为什么用原生 PHP 而非 Laravel/Swoole 扩展?

2.1 代付场景对框架的隐性约束:状态强一致 > 并发吞吐量

代付不是普通 HTTP 请求,它本质是“资金指令”的原子操作。一次代付请求需完成:① 本地生成唯一代付单号并落库(status=init);② 构造带时间戳、随机串、业务参数的签名报文;③ 向淘宝开放平台 POST 提交;④ 接收同步返回(仅表示请求被接收,不表示支付成功);⑤ 等待淘宝异步回调(notify_url)确认最终结果;⑥ 根据回调内容更新本地订单状态并触发后续动作(如发货、发短信)。这个链路里,第⑤步异步回调的可靠性远高于第④步同步响应——淘宝文档明确说明“同步返回仅作请求受理标识,最终结果以异步通知为准”。这意味着:框架必须支持高可靠的消息重试(失败后按 1m/5m/15m/30m/1h 递增重试)、本地状态幂等更新(同一 notify_id 只处理一次)、以及回调验签失败时的隔离告警。Laravel 的 Queue + Horizon 虽能做重试,但默认不内置淘宝 RSA2 公钥验签中间件;Swoole 的协程虽快,但若未显式控制回调处理的事务边界,极易出现“验签通过但数据库更新失败,下次重试又重复扣款”的灾难。本系统选择原生 PHP + PDO + 纯函数式设计,正是为了把每一步状态变更都暴露在开发者眼皮底下——你可以直接在updateOrderStatus()函数里加beginTransaction(),也可以在handleNotify()开头就INSERT IGNORE INTO notify_log记录原始回调数据,这种可控性在资金类系统里不是“可选项”,而是底线。

2.2 加密模块选型:OpenSSL vs phpseclib —— 生产环境必须用 OpenSSL

淘宝/天猫开放平台强制要求使用 RSA2(SHA256withRSA)签名,且公钥格式为 PEM(-----BEGIN PUBLIC KEY-----)。很多 PHP 示例用 phpseclib 库,因其纯 PHP 实现、无需编译扩展。但实际压测发现:phpseclib 在高并发下 CPU 占用飙升,且对 PEM 公钥解析存在兼容性问题(某些淘宝提供的公钥会因换行符缺失导致loadKey()失败)。而 OpenSSL 扩展(PHP 7.1+ 默认启用)性能稳定,API 明确:

// 正确做法:用 OpenSSL 原生函数生成签名 $privateKey = openssl_pkey_get_private(file_get_contents('/path/to/private_key.pem')); $data = 'app_key=xxx&method=taobao.trades.sold.get&...'; openssl_sign($data, $signature, $privateKey, OPENSSL_ALGO_SHA256); $sign = base64_encode($signature);

提示:openssl_sign()第三个参数必须是OPENSSL_ALGO_SHA256,不能写'sha256'字符串,否则签名无效。淘宝文档未强调此细节,但实测中 90% 的签名失败源于此。

2.3 异步通知接收层:Nginx 配置比 PHP 代码更重要

代付回调地址(notify_url)必须是公网可访问的 HTTPS 接口,且淘宝会高频重试失败通知。很多人只关注 PHP 代码是否能解析 POST 数据,却忽略 Nginx 层的致命配置:

# 必须关闭缓冲,确保淘宝发送的原始 POST body 不被截断 client_max_body_size 10M; client_body_buffer_size 10M; proxy_buffering off; # 关键:禁用 gzip,淘宝回调不带 Accept-Encoding,gzip 会导致 body 解析为空 gzip off; # 设置合理的超时,避免淘宝因等待过久而判定回调失败 fastcgi_read_timeout 300; proxy_read_timeout 300;

注意:若使用 CDN 或 WAF,需确认其未对 POST body 做非法清洗(如过滤特殊字符+、/),淘宝回调参数中sign字段含大量 Base64 字符,被误删将导致验签永远失败。


3. 核心代付流程实现:从下单到状态闭环的六步代码拆解

3.1 代付单创建与本地状态初始化

代付开始前,必须在本地数据库生成唯一、不可篡改的代付单(pay_order),这是整个资金链路的锚点。关键字段设计如下:

字段名类型说明
pay_order_noVARCHAR(32) PK业务单号,建议用date('ymd').uniqid()生成,避免泄露业务量
trade_noVARCHAR(64)淘宝交易号(taobao_trade_id),用于后续查询和退款
amountDECIMAL(10,2)代付金额(单位:元),必须与淘宝订单金额严格一致
statusTINYINT状态码:0=init, 1=submitting, 2=paid, 3=failed, 4=refunded
notify_timesTINYINT回调处理次数,超过3次触发人工核查
created_atDATETIME创建时间,用于超时关单
// create_pay_order.php function createPayOrder($tradeNo, $amount, $buyerNick) { $pdo = getPDO(); // 获取 PDO 连接 $payOrderNo = date('ymd') . substr(md5(uniqid()), 0, 16); $stmt = $pdo->prepare("INSERT INTO pay_order (pay_order_no, trade_no, amount, buyer_nick, status, created_at) VALUES (?, ?, ?, ?, 0, NOW())"); $stmt->execute([$payOrderNo, $tradeNo, $amount, $buyerNick]); return $payOrderNo; } $payOrderNo = createPayOrder('1234567890123456789', 99.90, 'taobao_user_abc'); echo "代付单已创建:{$payOrderNo}\n"; // 输出:代付单已创建:240520a1b2c3d4e5f6g7h8

逻辑说明:此处status=0表示初始态,后续所有状态变更(包括失败回滚)都必须通过UPDATE ... WHERE pay_order_no = ? AND status = X的条件更新,防止并发冲突。buyer_nick字段用于在淘宝后台人工核对时快速定位买家,非必需但强烈建议记录。

3.2 构造代付请求参数并签名

淘宝代付接口(taobao.trades.pay)要求参数按字典序拼接后签名。注意:所有参数值必须 URL 编码,但等号=和与号&不编码;签名原文不包含sign字段本身。本系统封装为buildSignParams()函数:

// sign_utils.php function buildSignParams($params, $privateKeyPath) { // 1. 参数排序(按 key 字典序) ksort($params); // 2. 拼接字符串:key1=value1&key2=value2... $stringToSign = ''; foreach ($params as $k => $v) { // value 必须 urlencode,但 k=v 中的 = 和 & 不 urlencode $stringToSign .= $k . '=' . urlencode($v) . '&'; } $stringToSign = rtrim($stringToSign, '&'); // 去掉末尾 & // 3. 用私钥签名(RSA2) $privateKey = openssl_pkey_get_private(file_get_contents($privateKeyPath)); openssl_sign($stringToSign, $signature, $privateKey, OPENSSL_ALGO_SHA256); return [ 'params' => $params, 'sign' => base64_encode($signature), 'string_to_sign' => $stringToSign // 仅用于调试日志 ]; } // 使用示例 $params = [ 'app_key' => 'your_app_key', 'method' => 'taobao.trades.pay', 'format' => 'json', 'v' => '2.0', 'partner_id' => 'top-apitools', 'sign_method' => 'rsa2', 'timestamp' => date('Y-m-d H:i:s'), 'fields' => 'tid,pay_time,status', 'tid' => '1234567890123456789', // 淘宝交易号 'use_cod' => 'false', 'is_force_cod' => 'false' ]; $result = buildSignParams($params, '/path/to/rsa_private_key.pem'); print_r($result['params']); // 查看最终提交参数 echo "签名原文:{$result['string_to_sign']}\n"; echo "签名值:{$result['sign']}\n";

参数说明:tid是淘宝交易号,必须与创建代付单时传入的一致;use_cod设为false表示不使用货到付款;is_force_cod为false表示不强制货到付款。这两个参数若设为true,将导致代付失败并返回invalid_parameter错误,但淘宝错误码文档未明确列出此限制,属实测踩坑。

3.3 发起代付请求并处理同步响应

调用淘宝 API 使用标准 cURL,重点在于设置超时、SSL 验证和错误捕获:

// api_client.php function callTaobaoAPI($url, $params, $timeout = 30) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 生产环境应设为 true 并配置 CA 证书 curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_HEADER, false); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error) { throw new Exception("cURL Error: {$error}"); } if ($httpCode !== 200) { throw new Exception("HTTP {$httpCode} Error"); } return json_decode($response, true); } // 调用示例 try { $apiUrl = 'https://eco.taobao.com/router/rest'; $response = callTaobaoAPI($apiUrl, $result['params']); if (isset($response['error_response'])) { // 淘宝返回业务错误,如余额不足、权限不足 error_log("淘宝API错误:{$response['error_response']['msg']} (code: {$response['error_response']['code']})"); updatePayOrderStatus($payOrderNo, 3, $response['error_response']['msg']); // 更新为 failed exit; } // 成功响应示例:{"taobao_trades_pay_response":{"trades":{"trade":[{"tid":"1234567890123456789","pay_time":"2024-05-20 14:22:33","status":"WAIT_BUYER_CONFIRM_GOODS"}]}}} $trade = $response['taobao_trades_pay_response']['trades']['trade'][0]; updatePayOrderStatus($payOrderNo, 1, "已提交代付请求"); // 更新为 submitting echo "代付请求已提交,淘宝交易号:{$trade['tid']}\n"; } catch (Exception $e) { error_log("代付请求异常:{$e->getMessage()}"); updatePayOrderStatus($payOrderNo, 3, "网络请求失败:{$e->getMessage()}"); }

逻辑说明:updatePayOrderStatus()函数需实现乐观锁更新,例如UPDATE pay_order SET status=1, updated_at=NOW() WHERE pay_order_no=? AND status=0,确保只有初始态才能被更新。若返回affected_rows=0,说明已被其他进程抢先更新,需记录冲突日志。

3.4 异步通知接收与验签:淘宝回调的完整解析链

淘宝回调是 POST 请求,原始 body 为application/x-www-form-urlencoded格式,但必须用file_get_contents('php://input')读取原始数据,不能依赖$_POST,因为淘宝可能在参数中携带+号(Base64 签名含+),而$_POST会将其转为空格导致验签失败。

// notify_handler.php function handleTaobaoNotify() { // 1. 获取原始 POST body $rawBody = file_get_contents('php://input'); if (!$rawBody) { http_response_code(400); echo "Invalid request"; return; } // 2. 解析为关联数组(注意:不经过 $_POST,手动 parse) parse_str($rawBody, $notifyData); // 3. 提取 sign 和 sign_method if (!isset($notifyData['sign']) || !isset($notifyData['sign_method'])) { http_response_code(400); echo "Missing sign or sign_method"; return; } $sign = $notifyData['sign']; $signMethod = $notifyData['sign_method']; // 4. 移除 sign 和 sign_method 后重新排序拼接(验签原文) unset($notifyData['sign'], $notifyData['sign_method']); ksort($notifyData); $stringToVerify = ''; foreach ($notifyData as $k => $v) { $stringToVerify .= $k . '=' . $v . '&'; // 注意:此处 value 不 urlencode!淘宝回调 value 是已解码的 } $stringToVerify = rtrim($stringToVerify, '&'); // 5. 用淘宝公钥验签(RSA2) $publicKey = openssl_pkey_get_public(file_get_contents('/path/to/taobao_public_key.pem')); $result = openssl_verify($stringToVerify, base64_decode($sign), $publicKey, OPENSSL_ALGO_SHA256); if ($result !== 1) { error_log("验签失败!原文:{$stringToVerify},签名:{$sign}"); http_response_code(400); echo "Invalid sign"; return; } // 6. 验签通过,处理业务逻辑 processNotify($notifyData); echo "success"; // 必须返回 success 字符串,否则淘宝持续重试 } function processNotify($data) { $tid = $data['tid'] ?? ''; $status = $data['status'] ?? ''; // 根据淘宝状态映射本地状态 $map = [ 'TRADE_SUCCESS' => 2, // 代付成功 'TRADE_CLOSED' => 3, // 代付关闭(如超时未支付) 'TRADE_REFUNDED' => 4, // 已退款 ]; $newStatus = $map[$status] ?? 3; // 使用 notify_id 做幂等(淘宝保证同一事件 notify_id 唯一) $notifyId = $data['notify_id'] ?? uniqid('notify_'); $pdo = getPDO(); $stmt = $pdo->prepare("SELECT COUNT(*) FROM notify_log WHERE notify_id = ?"); $stmt->execute([$notifyId]); if ($stmt->fetchColumn() > 0) { error_log("重复通知:{$notifyId}"); return; // 已处理过,直接返回 } // 记录日志 $stmt = $pdo->prepare("INSERT INTO notify_log (notify_id, raw_data, created_at) VALUES (?, ?, NOW())"); $stmt->execute([$notifyId, json_encode($data)]); // 更新代付单状态 updatePayOrderStatusByTid($tid, $newStatus, "淘宝回调:{$status}"); }

关键点:验签原文中的value是淘宝回调时已 URL 解码的值,所以拼接时直接用$v,切勿再urlencode($v)。这是淘宝文档未明说、但实测 100% 导致验签失败的玄学点。

3.5 代付状态轮询:当异步通知失效时的后悔药

淘宝回调可能因网络抖动、DNS 故障、你的服务器临时宕机而丢失。因此,必须实现定时轮询(polling)作为兜底。本系统设计为:代付单创建后 5 分钟内未收到有效回调,则启动轮询;每 2 分钟查一次,最多查 5 次(共 10 分钟),超时则标记为unknown并告警。

// polling_job.php function pollPayOrderStatus($payOrderNo, $maxRetries = 5) { $pdo = getPDO(); $stmt = $pdo->prepare("SELECT trade_no, status FROM pay_order WHERE pay_order_no = ?"); $stmt->execute([$payOrderNo]); $order = $stmt->fetch(PDO::FETCH_ASSOC); if (!$order || $order['status'] != 1) { // 只轮询 status=submitting 的单 return; } $tradeNo = $order['trade_no']; $url = 'https://eco.taobao.com/router/rest'; $params = [ 'app_key' => 'your_app_key', 'method' => 'taobao.trades.sold.get', 'fields' => 'tid,type,status,pay_time,created', 'tid' => $tradeNo, 'format' => 'json', 'v' => '2.0', 'sign_method' => 'rsa2', 'timestamp' => date('Y-m-d H:i:s') ]; // 构造并签名(复用 3.2 节函数) $signed = buildSignParams($params, '/path/to/private_key.pem'); $params = array_merge($params, ['sign' => $signed['sign']]); try { $response = callTaobaoAPI($url, $params); if (isset($response['error_response'])) { error_log("轮询API错误:{$response['error_response']['msg']}"); return; } $trade = $response['taobao_trades_sold_get_response']['trades']['trade'][0]; $statusMap = [ 'WAIT_BUYER_PAY' => 1, // 仍在等待买家付款(代付未完成) 'WAIT_SELLER_SEND_GOODS' => 2, // 买家已付款,卖家待发货(代付成功) 'TRADE_CLOSED' => 3, // 交易关闭 ]; $newStatus = $statusMap[$trade['status']] ?? 3; updatePayOrderStatus($payOrderNo, $newStatus, "轮询获取状态:{$trade['status']}"); } catch (Exception $e) { error_log("轮询异常:{$e->getMessage()}"); } } // 在 crontab 中每 2 分钟执行一次 // */2 * * * * /usr/bin/php /path/to/polling_job.php

参数说明:taobao.trades.sold.get接口返回的status字段值与回调中的status不完全一致,需做映射。例如回调中TRADE_SUCCESS对应轮询中WAIT_SELLER_SEND_GOODS,这是淘宝状态机设计导致的,必须硬编码转换。


4. 避坑指南:淘宝代付系统上线前必须验证的五个边界问题

4.1 现象:回调验签始终失败,日志显示openssl_verify() returns 0

原因:淘宝公钥文件格式错误。淘宝提供的是PKCS#8格式公钥(以-----BEGIN PUBLIC KEY-----开头),但部分运维同学误用openssl rsa -pubin -in taobao.pub -text -noout命令查看时,若公钥被意外修改(如 Windows 换行符\r\n未被正确处理),或文件末尾有多余空格,openssl_pkey_get_public()会静默失败,返回false,导致openssl_verify()输入无效公钥而返回0。
解决:用file命令确认公钥类型:file taobao_public_key.pem应输出taobao_public_key.pem: PEM certificate;用head -n 1 taobao_public_key.pem确认首行为-----BEGIN PUBLIC KEY-----;用tail -n 1 taobao_public_key.pem | xargs echo确保末尾无空格。终极方案:用openssl x509 -pubkey -noout -in cert.crt > pub.key从证书中提取公钥。

4.2 现象:代付成功后,淘宝后台显示“已付款”,但本地订单状态仍为submitting

原因:淘宝回调的notify_id参数被你的 Nginx 或 CDN 缓存了。淘宝文档说明notify_id全局唯一,但若 Nginx 配置了proxy_cache或 CDN 启用了 POST 缓存,同一notify_id的回调可能被缓存并返回给后续请求,导致notify_log表中重复插入失败,processNotify()被跳过。
解决:在 Nginx 的 notify location 块中强制关闭缓存:

location /notify/ { proxy_cache off; proxy_no_cache 1; proxy_cache_bypass 1; # 其他配置... }

4.3 现象:轮询接口taobao.trades.sold.get返回isv.permission-api-limit-error

原因:该接口有严格的 QPS 限制(通常为 10 次/秒),而轮询脚本未做限流。当多个代付单同时进入轮询期,脚本并发调用导致触发淘宝限流,返回权限错误。
解决:在轮询脚本中加入分布式锁和速率控制。使用 Redis 实现简单令牌桶:

$redis = new Redis(); $redis->connect('127.0.0.1', 6379); $key = 'taobao_api_token_bucket'; $now = time(); $pipe = $redis->multi(Redis::PIPELINE); $pipe->zRemRangeByScore($key, 0, $now - 1); // 清理 1 秒前的 token $pipe->zCard($key); // 获取当前 token 数 $pipe->zAdd($key, $now, uniqid()); // 添加新 token $result = $pipe->exec(); if ($result[1] >= 10) { // 当前 1 秒内已超 10 次 sleep(1); return; }

4.4 现象:用户退款后,淘宝回调status=TRADE_REFUNDED,但本地未触发退款流水

原因:淘宝代付系统中,“退款”是独立于“代付”的另一条链路。TRADE_REFUNDED仅表示淘宝交易已退款,但你的系统并未自动发起对用户的原路退款。必须监听此状态,并调用你自己的支付渠道(如微信/支付宝)API 向用户退款。
解决:在processNotify()中增加退款分支:

if ($status === 'TRADE_REFUNDED') { $refundAmount = getRefundAmountFromTaobao($tid); // 需调用 taobao.refunds.get 获取实际退款额 initiateRefundToUser($payOrderNo, $refundAmount); // 调用你自己的退款接口 }

注意:taobao.refunds.get接口需单独申请权限,且返回的refund_fee是淘宝收取手续费后的净额,需与本地订单金额比对,防止恶意刷单。

4.5 现象:taobao.trades.pay接口返回isv.invalid-permission,但应用权限已勾选“代付”

原因:淘宝开放平台的“代付”权限分为两级:一级是应用级别的“代付权限”,二级是具体子账号的“代付操作权限”。即使应用开通了代付,若调用 API 时使用的user_nick(即fields参数中的nick)对应的子账号未在淘宝卖家后台手动开启“代付”开关,仍会报此错。
解决:登录淘宝卖家后台 → 店铺管理 → 子账号管理 → 找到对应子账号 → 编辑权限 → 在“交易”分类下勾选“代付”。


5. 生产环境加固:从日志审计到资金对账的闭环验证技巧

5.1 构建三重日志审计体系:原始数据、签名过程、状态变迁

代付系统的可追溯性直接决定资金安全等级。我一般会强制记录三类日志,且全部写入独立日志文件(不混入 access.log):

日志类型文件路径记录内容用途
原始回调日志/var/log/taobao/notify_raw.logfile_get_contents('php://input')的完整字符串用于验签失败时人工比对原文
签名调试日志/var/log/taobao/sign_debug.logbuildSignParams()中的$stringToSign和$sign定位签名不一致问题(如时间戳不同步)
状态变迁日志/var/log/taobao/order_status.logUPDATE pay_order SET status=X WHERE ...的完整 SQL 和影响行数审计资金状态是否被非法篡改
// log_utils.php function logRawNotify($rawBody) { $logLine = date('Y-m-d H:i:s') . " [RAW] " . $rawBody . "\n"; file_put_contents('/var/log/taobao/notify_raw.log', $logLine, FILE_APPEND | LOCK_EX); } function logSignDebug($stringToSign, $sign) { $logLine = date('Y-m-d H:i:s') . " [SIGN] string='{$stringToSign}' sign='{$sign}'\n"; file_put_contents('/var/log/taobao/sign_debug.log', $logLine, FILE_APPEND | LOCK_EX); } function logStatusChange($payOrderNo, $fromStatus, $toStatus, $reason) { $logLine = date('Y-m-d H:i:s') . " [STATUS] order={$payOrderNo} from={$fromStatus} to={$toStatus} reason='{$reason}'\n"; file_put_contents('/var/log/taobao/order_status.log', $logLine, FILE_APPEND | LOCK_EX); }

关键实践:这三类日志必须开启LOCK_EX(文件排他锁),防止多进程写入时日志错乱。曾有项目因未加锁,导致notify_raw.log中两笔回调日志粘连成一行,无法解析,耽误了 4 小时故障定位。

5.2 每日资金对账脚本:用 SQL 直接比对淘宝与本地流水

对账不是财务部门的事,而是代付系统上线后的每日必修课。核心逻辑是:淘宝侧的“代付成功”流水总和,必须等于本地pay_order表中status=2的amount总和。我写的对账脚本直接用 PDO 执行两条 SQL,输出差异:

// daily_recon.php function runDailyReconciliation($date = null) { $date = $date ?: date('Y-m-d'); $pdo = getPDO(); // 1. 查询本地代付成功总额(status=2) $stmt = $pdo->prepare(" SELECT COALESCE(SUM(amount), 0) as local_total FROM pay_order WHERE DATE(created_at) = ? AND status = 2 "); $stmt->execute([$date]); $localTotal = $stmt->fetchColumn(); // 2. 查询淘宝侧代付成功总额(需提前将淘宝导出的 CSV 流水导入 mysql 表 taobao_pay_log) // 淘宝 CSV 字段:tid, pay_time, status, payment, created $stmt = $pdo->prepare(" SELECT COALESCE(SUM(payment), 0) as taobao_total FROM taobao_pay_log WHERE DATE(pay_time) = ? AND status = 'TRADE_SUCCESS' "); $stmt->execute([$date]); $taobaoTotal = $stmt->fetchColumn(); $diff = round($localTotal - $taobaoTotal, 2); if ($diff == 0) { echo "[OK] {$date} 对账平衡:本地 {$localTotal} = 淘宝 {$taobaoTotal}\n"; return true; } else { echo "[ALERT] {$date} 对账差异:{$diff} (本地 {$localTotal} ≠ 淘宝 {$taobaoTotal})\n"; // 输出差异明细 $stmt = $pdo->prepare(" SELECT pay_order_no, trade_no, amount, created_at FROM pay_order WHERE DATE(created_at) = ? AND status = 2 AND pay_order_no NOT IN ( SELECT DISTINCT SUBSTRING_INDEX(trade_no, '_', -1) FROM taobao_pay_log WHERE DATE(pay_time) = ? ) "); $stmt->execute([$date, $date]); $missing = $stmt->fetchAll(PDO::FETCH_ASSOC); if (!empty($missing)) { echo "本地有但淘宝无:\n"; foreach ($missing as $row) { echo " {$row['pay_order_no']} ({$row['trade_no']}) {$row['amount']} @ {$row['created_at']}\n"; } } return false; } } // 在 crontab 中每天凌晨 2 点执行 // 0 2 * * * /usr/bin/php /path/to/daily_recon.php

参数说明:taobao_pay_log表需每日从淘宝卖家后台导出 CSV 后,用LOAD DATA INFILE导入。关键字段trade_no在淘宝 CSV 中是完整交易号,而本地pay_order.trade_no可能做了脱敏(如只存后 12 位),因此SUBSTRING_INDEX用于提取可比对的部分。这个脚本跑通后,我每次上线新代付功能,都会先跑一周对账,确保零差异才敢放量。

5.3 退款冲正机制:当淘宝退款成功但本地失败时的自动修复

最危险的场景是:淘宝已退款,但你的系统因数据库死锁、网络超时等原因,未能更新pay_order.status为4,导致这笔钱“消失”在系统里。为此,我设计了一个“退款冲正”脚本,它不依赖回调,而是主动扫描:

// refund_compensation.php function runRefundCompensation($hoursAgo = 24) { $pdo = getPDO(); $cutoffTime = date('Y-m-d H:i:s', time() - $hoursAgo * 3600); // 查找淘宝已退款但本地状态非 4 的单 $stmt = $pdo->prepare(" SELECT p.pay_order_no, p.trade_no, p.amount FROM pay_order p LEFT JOIN taobao_pay_log t ON p.trade_no = t.tid WHERE p.status != 4 AND t.status = 'TRADE_REFUNDED' AND t.pay_time > ? AND p.created_at > ? "); $stmt->execute([$cutoffTime, $cutoffTime]); $orders = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($orders as $order) { // 二次确认:调用 taobao.refunds.get 确保退款已发生 $refundInfo = getTaobaoRefundInfo($order['trade_no']); if ($refundInfo && $refundInfo['status'] === 'SUCCESS') { // 强制更新本地状态 $pdo->prepare("UPDATE pay_order SET status = 4, updated_at = NOW() WHERE pay_order_no = ?")->execute([$order['pay_order_no']]); // 补充退款流水 insertRefundRecord($order['pay_order_no'], $refundInfo['refund_fee']); echo "已冲正退款:{$order <p> <a href="https://download.csdn.net/download/sd4651346/88318230" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进