简介:支付宝免签约即时到帐接口 for ECShop_V2.7.3 是一套面向 ECShop 商城运营者与二次开发者的支付接入组件,适合在尚未开通支付宝签约商户号、又希望商城具备即时到帐收款与异步通知能力的场景中使用。压缩包共 7 个文件,以 6 个 PHP 接口、支付模块和中英繁语言文件为主体,另附 1 个 TXT 使用说明,整体约 7KB,结构精简,职责划分清晰,重点覆盖收款通知接收、后台支付方式挂载及多语言适配等环节。已有 318 人学习下载,对中小型商城快速验证免签约收款流程具有一定参考意义。读者可从中了解通知回调与支付模块的组织方式,便于对照现有 ECShop 站点做接入评估、功能排查和后续二次整合,也能为类似商城系统的支付扩展提供可复用的思路,减少从零对接与定位问题的时间成本。
1. 免签约即时到账接口:商城系统 2.7.3 里塞一个能用的小额收款通道
接手一个跑了七八年的老商城,客户提的要求往往很朴素:能把钱收上来就行。可现实是,正规通道要营业执照、要对公账户、要审核周期,而手上只有一套 2.7.3 版本的商城源码和一个个人收款账户。于是免签约即时到账接口这种老方案又被翻了出来——它的本质并不神秘,就是把订单参数拼成一个表单,用合作身份者 ID 加安全校验码做一次 MD5 签名,浏览器跳转到支付网关,钱到账后网关再往你指定的回调地址 POST 一串参数,你验签、比对金额、改订单状态,完事。
这套东西适合两类人:一类是维护老商城、只想把收款链路补上的开发者,另一类是刚接触支付回调、想弄明白"从点击付款到订单变已付款"中间到底发生了什么的人。它不适合大额交易和需要分账、退款的正式商业场景,但在测试环境、内部小工具、老项目救急上,它是真能跑起来的。
2. 目录结构与接口挂载点:支付插件是怎么被商城系统认出来的
商城系统的支付模块设计得很朴素,没有依赖注入,没有事件总线,就是按文件名找类、按类取配置。理解这一点,后面所有调试都会顺很多。一个支付通道要能在后台"支付方式"列表里出现,并且在用户下单时被调用,靠的不是配置,而是三个地方的名字必须完全对齐:模块文件名、类名、以及类里返回的 code。
2.1 三个文件决定一个通道能不能被识别
先看目录,2.7.3 这套结构的支付相关文件集中在这几处:
| 路径 | 作用 | 是否要改 |
|---|---|---|
includes/modules/payment/ | 存放各支付通道的插件类,一个文件一个通道 | 新增 |
languages/zh_cn/payment/ | 对应通道的语言包,后台配置项的标题和说明写在这里 | 新增 |
includes/lib_payment.php | 支付抽象基类,定义所有钩子方法 | 不改 |
admin/payment.php | 后台支付方式管理,负责安装、编辑、启用 | 不改 |
order.php/flow.php | 下单与结算流程,负责实例化插件并跳转 | 不改 |
插件文件名一般取一个短代号,比如instantpay.php,那类名就得是instantpay,语言包文件名也得是instantpay.php。后台在扫描includes/modules/payment/目录时,会遍历所有 php 文件,include进来后用文件名去new对应的类,再调用get_code()判断是否已安装。三者但凡有一个对不上,后台的"支付方式"页面上就是一片空白,而且不报错——这是新手最容易卡住的地方,因为没有任何错误提示,你会以为是权限问题或者缓存问题。
语言包看着不起眼,但少了它后台配置页面会显示空白标签,运营看了以为坏了。语言包的结构是两层数组,第一层是通道名和描述,第二层是各配置项的标题与说明:
<?php // languages/zh_cn/payment/instantpay.php $_LANG['instantpay'] = '免签约即时到账'; $_LANG['instantpay_desc'] = '通过表单跳转完成小额收款,回调验签后自动改单'; $_LANG['instantpay_account'] = '合作身份者ID'; $_LANG['instantpay_key'] = '安全校验码'; $_LANG['instantpay_email'] = '收款账号'; $_LANG['instantpay_currency'] = '支付币种';数组键名规则是"通道代号 + 下划线 + 配置字段名",配置页面会自动按这个约定去取标题。也就是说,插件类里声明了哪几个配置字段,语言包里就得有对应几条,多一条少一条都不会致命,但少的那条会显示成空字符串。
2.2 支付基类的几个钩子:每个方法管什么事
includes/lib_payment.php里的抽象类定义了一套钩子,插件类继承它之后按需实现。2.7.3 里真正用得上的其实就四五个:
<?php // includes/modules/payment/instantpay.php if (!defined('IN_ECS')) { die('Hacking attempt'); } class instantpay extends Payment { // 通道代号,必须和文件名、类名一致 var $code = 'instantpay'; var $name = '免签约即时到账'; // 后台配置表单:返回 HTML 字符串,由框架塞进支付方式编辑页 function get_content() { $html = ''; // 每个配置项一行,字段名带 code 前缀,框架保存时会自动剥掉 $html .= $this->get_config_field('account', '合作身份者ID', 'text'); $html .= $this->get_config_field('key', '安全校验码', 'text'); $html .= $this->get_config_field('email', '收款账号', 'text'); return $html; } // 下单时的支付按钮 / 表单,返回 HTML,由结算页直接输出 function get_content_pay($order) { return $this->build_form($order); } // 同步返回页(return_url)落地后的处理 function respond() { return $this->handle_return(); } }get_content()只在后台配置时被调用,跟用户下单没关系;get_content_pay()才是真正的支付入口,它拿到的是一个订单数组,返回一段 HTML,结算页会把这整段 HTML 原样输出给浏览器。这意味着你完全可以在这里返回一个自动提交的 form,而不是一个跳转链接——免签约接口就是这么干的。
respond()对应浏览器从网关跳回来的那个页面。很多人以为这里可以写回写订单的逻辑,其实不行,因为同步返回是不可信的、可以被用户关掉、可以被伪造。它只适合用来做"页面提示 + 跳转到订单详情",真正的改单必须放在异步通知里。
2.3 配置项怎么落库、下单时怎么取出来
后台填的 ID、密钥、收款账号,最终都存进支付方式表。2.7.3 的ecs_payment表里有个pay_config字段,存的是序列化后的数组。想确认自己填的配置有没有生效,最快的办法是直接查库:
-- 查当前启用的支付方式及其配置 SELECT pay_id, pay_code, pay_name, pay_fee, is_cod, enabled, pay_config FROM ecs_payment WHERE pay_code = 'instantpay';pay_config出来是一串a:3:{s:7:"account";s:16:"2088xxxxxxxxxxxx";...}这样的序列化字符串,肉眼可读性一般,但至少能确认三条配置都进去了。如果查出来是空的,问题多半在get_content()里字段名的前缀不对,框架按约定剥前缀时没匹配上,值就丢了。
运行时取配置不用自己反序列化,基类提供了get_config($key):
// 在插件方法内部取配置,第二个参数是默认值 $partner = $this->get_config('account'); $sign_key = $this->get_config('key'); $seller = $this->get_config('email'); if (empty($partner) || empty($sign_key)) { // 配置缺失时别硬着头皮往下跑,直接抛给前台一个可读的提示 return '<div class="pay-error">支付通道未配置完整,请联系管理员</div>'; }这里有个血泪经验:配置项为空时如果不拦截,签名会用一个空密钥算出来,网关会返回"签名校验失败",而你在本地怎么调试都看不出问题。所以取完配置第一件事就是判空并给明确提示,这一步能省掉后面半小时的抓瞎。
3. 支付请求构造:从订单号到表单自动提交的完整拼装
订单页点到"确认支付"那一刻,插件要做的事情很线性:把订单里的金额、单号、标题取出来,加上商户参数和回调地址,组成一个参数数组,排序、拼串、算 MD5,然后把整个参数数组渲染成一堆隐藏域,用 JavaScript 提交。整个过程没有任何服务端交互,所以不存在"请求超时"这回事,但也意味着参数错一个,用户就直接看到网关的报错页。
3.1 参数数组与 MD5 签名
签名算法本身不复杂,麻烦的是规则必须一字不差。核心就三条:去掉sign和sign_type两个字段、按参数名的 ASCII 码从小到大排序、用key=value&的形式拼接后末尾追加密钥再做 MD5。少一个&、多一个空格、大小写不一致,验签都过不去。
<?php /** * 生成签名 * @param array $params 待签名参数(不含 sign / sign_type) * @param string $key 安全校验码 * @return string 32 位小写 MD5 */ function build_sign($params, $key) { // 签名不参与计算,先剔掉,避免二次调用时出错 unset($params['sign'], $params['sign_type']); // 空值不参与签名,这是最容易忽略的一条 $params = array_filter($params, function ($v) { return $v !== '' && $v !== null; }); // 按参数名 ASCII 升序 ksort($params); // 拼成 a=1&b=2 的形式,末尾直接接 key $pairs = array(); foreach ($params as $k => $v) { $pairs[] = $k . '=' . $v; } $raw = implode('&', $pairs) . $key; // 统一小写,避免不同环境 md5 输出大小写差异 return strtolower(md5($raw)); }array_filter那一步是关键。订单里有些字段比如备注、扩展参数,可能是空字符串,如果不剔掉,签名串里就会多出一个ext=&&这样的片段,网关那边按"忽略空值"的规则算出来自然对不上。我在两个不同项目里都栽过这个坑,最后一次索性在build_sign里做统一过滤,调用方不用再关心。
金额的格式化同样要卡死。total_fee必须是两位小数的字符串,19.9和19.90在网关那边不是一个东西:
// 金额统一格式化为两位小数字符串,别用 floatval 直接转 $order_amount = sprintf('%.2f', $order['order_amount']); // 商品标题做长度截断,超长会被网关拒绝 $subject = mb_substr($order['order_sn'] . ' 订单支付', 0, 64, 'utf-8'); $params = array( 'service' => 'create_direct_pay_by_user', // 即时到账交易服务名 'partner' => $partner, // 16 位合作身份者 ID 'seller_email' => $seller, // 收款账号 'out_trade_no' => $order['order_sn'], // 商城订单号,全局唯一 'subject' => $subject, 'total_fee' => $order_amount, 'payment_type' => '1', // 1 表示商品购买 'notify_url' => $notify_url, // 异步通知,必须公网可达 'return_url' => $return_url, // 同步跳回,仅用于展示 '_input_charset' => 'utf-8', );out_trade_no建议直接用商城的order_sn而不是自增order_id。前者带日期和随机位,天然唯一且不泄露业务量;后者在测试环境重置数据后容易和网关侧的历史单号撞车,撞了就会收到"交易已存在"的错误。
3.2 表单 HTML 的生成与自动提交
参数齐了、签名有了,剩下就是拼一段 HTML 交给结算页。这里不要用header('Location: ...')去跳转,因为参数一多 URL 会超长,而且部分网关要求 POST。
<?php function build_form($order) { $params = $this->prepare_params($order); // 上一节的参数组装 $params['sign'] = build_sign($params, $this->get_config('key')); $params['sign_type'] = 'MD5'; // 网关地址单独配置或用常量,方便测试环境切沙箱 $gateway = 'https://网关域名/gateway.do?_input_charset=utf-8'; $html = '<form id="pay-form" action="' . $gateway . '" method="post" accept-charset="utf-8">'; foreach ($params as $k => $v) { // 全部做 htmlspecialchars,防止标题里的引号把 form 结构打断 $html .= '<input type="hidden" name="' . $k . '" value="' . htmlspecialchars($v, ENT_QUOTES, 'utf-8') . '" />'; } $html .= '</form>'; $html .= '<script>document.getElementById("pay-form").submit();</script>'; return $html; }htmlspecialchars这一步不是可选项。商品标题里带&或者英文双引号是很常见的,比如"键盘&鼠标套装",不做转义就会把value="..."提前闭合,表单提交上去subject变成截断的字符串,签名立刻失效。这类问题在测试数据里几乎不会出现,一上真实商品就炸。
自动提交用内联<script>是够的,但要注意这段 HTML 最终是被塞进结算页模板输出的,如果模板本身在</body>之后还有别的脚本,提交时机可能被打断。稳妥一点可以包一层window.onload或者setTimeout(..., 0),让浏览器先把 DOM 收尾。我一般还会在表单下方放一句"正在跳转支付页面,请勿关闭浏览器",万一脚本被拦截,用户至少知道发生了什么,而不是对着一个空白页面发呆。
3.3 订单号防重与支付方式回写
用户点两次"支付"是常态,尤其是在网速慢的时候。插件层能做的是在生成表单前,把这个订单当前选中的支付方式写回订单表,同时判断一下订单是否已经支付过:
// 下单时记录本次选用的支付方式,方便回调时比对 $sql = "UPDATE " . $GLOBALS['ecs']->table('order_info') . " SET pay_id = '" . intval($this->pay_id) . "', pay_name = '" . $this->name . "'" . " WHERE order_id = '" . intval($order['order_id']) . "'" . " AND pay_status = 0"; $GLOBALS['db']->query($sql);带上pay_status = 0这个条件很关键。如果用户已经付过了又误点一次,这条 UPDATE 影响行数为 0,你就可以顺势拦下来,直接把页面导到订单详情,而不是再放他去网关重复下一单。这类"后悔药"式的小判断,比事后去网关后台退款省事得多。
4. 异步通知处理:notify 回调里的验签、幂等与订单状态机
异步通知是整个支付链路里唯一可信的改单时机,也是出问题最多的地方。网关会在交易状态变化时往notify_url发 POST,可能发一次,也可能发很多次,间隔从几秒到几小时不等,直到你返回一个明确的成功标识为止。所以这个接口必须做到三件事:验签、校验业务、幂等。少任何一件,轻则重复发货,重则被人伪造请求白嫖。
4.1 验签:先剔字段,再排序,再拼串
网关发过来的参数是原始 POST,键值和你提交时基本对应,外加一个sign和一个sign_type。验签的计算方式和生成签名完全对称,唯一区别是要用$_POST里的数据反过来算一遍再做比对。
<?php // notify.php —— 独立于商城框架的入口,先引入必要文件 define('IN_ECS', true); require dirname(__FILE__) . '/includes/init.php'; // 拿原始 POST,不要做任何 trim 之外的加工 $params = $_POST; $sign = isset($params['sign']) ? $params['sign'] : ''; if (empty($sign)) { echo 'fail'; exit; } // sign_type 只支持 MD5,其他类型直接拒 if (isset($params['sign_type']) && strtoupper($params['sign_type']) !== 'MD5') { echo 'fail'; exit; } // 从支付方式表里取密钥,而不是从插件实例取,避免框架未初始化的情况 $pay_config = unserialize($GLOBALS['db']->getOne( "SELECT pay_config FROM " . $GLOBALS['ecs']->table('payment') . " WHERE pay_code = 'instantpay'" )); $sign_key = isset($pay_config['key']) ? $pay_config['key'] : ''; $calc = build_sign($params, $sign_key); if ($calc !== $sign) { // 验签失败一定要记录原始报文,否则事后完全没法查 error_log('[instantpay] sign mismatch: ' . json_encode($params)); echo 'fail'; exit; }这里我把验签逻辑单独走一遍build_sign,而不是另写一套。原因是签名和验签但凡有一丝不对称,就会陷入"到底是参数问题还是算法问题"的拉锯。复用同一个函数,至少能排掉一半可能性。
error_log那一行是必须的。生产环境里最难受的不是验签失败,而是失败了却没有任何上下文,你只知道结果,不知道网关发了什么。把原始参数打出来,第二次出问题时直接对一下就清楚了。
4.2 业务校验三件套:金额、订单号、交易状态
签名对了只说明这段数据确实来自网关,不代表这笔钱该收。接下来的校验才是真正防止资损的地方:
<?php // 1) 交易状态:只处理成功的两种,其余直接吞掉并返回 success $trade_status = isset($params['trade_status']) ? $params['trade_status'] : ''; if (!in_array($trade_status, array('TRADE_SUCCESS', 'TRADE_FINISHED'))) { echo 'success'; // 非成功状态也要回 success,否则网关会一直重发 exit; } // 2) 订单号与金额 $order_sn = isset($params['out_trade_no']) ? trim($params['out_trade_no']) : ''; $amount = isset($params['total_fee']) ? $params['total_fee'] : ''; $order = $GLOBALS['db']->getRow( "SELECT order_id, order_sn, order_amount, pay_status, order_status FROM " . $GLOBALS['ecs']->table('order_info') . " WHERE order_sn = '" . $GLOBALS['db']->escape($order_sn) . "' LIMIT 1" ); if (empty($order)) { error_log('[instantpay] order not found: ' . $order_sn); echo 'success'; // 单号不存在也不重发,避免无意义的循环 exit; } // 金额必须严格比对,用字符串比较而不是浮点 if (sprintf('%.2f', $amount) !== sprintf('%.2f', $order['order_amount'])) { error_log('[instantpay] amount mismatch: ' . $order_sn . ' ' . $amount); echo 'fail'; // 金额不符是异常,让它重发,逼着人来查 exit; }金额比对用sprintf('%.2f')转成字符串再比,不要用==直接比较两个浮点数。19.9 != 19.90在浮点下也许相等,但反过来0.1 + 0.2这种场景会给你惊喜。用字符串就干净得多。
另一个要注意的是"金额不符时返回什么"。我一开始也是直接fail,结果网关按自己的重试策略持续回调,日志里刷了几百条。后来改成:验签失败返fail(可能是恶意请求,让它重发也没意义,但至少网关侧能看到失败),订单不存在返success(没什么可处理的),金额不符返fail(这是真正的异常,值得被重试和告警)。这一套策略不一定是最优,但至少归类清楚。
4.3 幂等与订单状态写入
同一笔交易被通知多次是正常的,网关不保证只发一次。所以改单动作必须幂等:判断当前订单是不是已经处于已付款状态,如果是就直接返回成功,不再做任何写操作。
<?php if ($order['pay_status'] == 2) { // 已付款,说明这是重复通知,直接确认 echo 'success'; exit; } // 开启事务,保证订单、日志、库存三处要么全成要么全不成 $GLOBALS['db']->query('START TRANSACTION'); $ok = $GLOBALS['db']->query( "UPDATE " . $GLOBALS['ecs']->table('order_info') . " SET order_status = 1, pay_status = 2, pay_time = " . time() . " WHERE order_id = " . intval($order['order_id']) . " AND pay_status = 0" ); // 支付流水表留痕,方便日后对账 $GLOBALS['db']->query( "INSERT INTO " . $GLOBALS['ecs']->table('pay_log') . " (order_id, order_amount, order_type, is_paid, trade_no) VALUES ('" . intval($order['order_id']) . "', '" . sprintf('%.2f', $amount) . "', '0', '1', '" . $GLOBALS['db']->escape($params['trade_no']) . "')" ); if ($ok === false) { $GLOBALS['db']->query('ROLLBACK'); echo 'fail'; exit; } $GLOBALS['db']->query('COMMIT'); echo 'success';UPDATE 的AND pay_status = 0是第二层幂等保险。如果两个通知同时到达,第一条改了状态,第二条的影响行数就是 0,其余逻辑自然就跳过了。这比先 SELECT 再判断更稳,不依赖程序层的时序。
trade_no是网关侧的交易流水号,一定要存。用户投诉"我明明付了",你可以拿这个号去后台对,比自己猜强得多。pay_log表在 2.7.3 里本来就存在,字段不够的话可以自己加一列存原始报文。
4.4 输出 success 的时机与那些看不见的污染
这段接口的返回值讲究精确到字节:只认小写的success,前后不能有空格、换行、BOM。多了任何字符,网关都会认为是失败,然后继续重发。
最常见的污染源是文件开头那个看不见的 BOM。编辑器另存为 UTF-8 带签名,文件头就多了三个字节,echo 'success'之前已经悄悄输出了一次,网关收到的是\xEF\xBB\xBFsuccess。排查方式很简单,用命令行看一眼:
# 查看响应头前几个字节,有没有 BOM curl -s -D - -o /tmp/notify.out "https://你的域名/notify.php" hexdump -C /tmp/notify.out | head -3 # 如果看到 ef bb bf 开头,就是这个坑第二个污染源是引入的框架文件里有?>结尾加空行。2.7.3 的老代码里这种写法不少,include进来就把空白 output 出去了。稳妥做法是在notify.php最前面加ob_start(),返回前ob_end_clean()再echo,把前面所有的杂音清掉。这招有点土,但真管用。
5. 排查手册:签名、编码与回调地址的高频翻车点
这套接口跑通之后其实很稳,但第一次接通的过程通常不会太顺。下面几条是我自己以及同事踩过的,按"现象—原因—解决"整理,遇到问题按顺序对一遍,基本能覆盖八成情况。
第一条:签名校验反复失败,本地算出来和网关对不上。现象是网关直接返回"签名错误",页面白底黑字一行提示。原因通常是参与签名的参数集合不一致——最常见的是中文商品标题在提交时用的是 UTF-8,而签名计算时用的还是商城默认编码,MD5 出来的结果自然不同。解决方式是把subject统一转一次编码,并且在参数数组构造完、签名之前做,不要中途改。另外确认一下total_fee是不是被 PHP 转成了浮点,19.90变19.9也算不出来。
第二条:回调地址填了内网 IP 或者带端口的测试域名,网关始终不通。现象是订单在网关那边显示已支付,但商城这边一直待付款。原因是notify_url必须是外网可达、能被访问到的地址,本地127.0.0.1或者局域网地址网关压根连不上。解决方式是先部署到有公网入口的测试环境,或者用内网穿透工具临时映射一个地址出来做联调,联调完再切回正式域名。注意notify_url要写完整 URL,包括协议,别写成相对路径。
第三条:网关频繁重发通知,业务日志里同一单出现十几次。现象是回调接口被反复调用,发货逻辑如果没做幂等就会重复发。原因多半是响应内容不对:要么echo前面有 BOM 或空白字符,要么返回的是SUCCESS大写的,要么返回了 JSON。解决方式是接口只输出success五个字符,前面加ob_clean()清掉所有输出,并用curl实测一次看返回体的十六进制。
第四条:同步返回页显示成功,但订单状态没变。现象是用户付款后跳回商城,页面提示已支付,进订单列表一看还是待付款。原因是同步返回和异步通知是两条独立链路,同步返回只能用来展示,不能改单。解决方式是页面只负责查询当前订单状态并展示,真正的改单逻辑一律放在异步通知里;如果用户跳回来时异步还没到,页面上给一句"支付结果确认中,请稍后刷新",别让他慌。
第五条:切换测试环境和生产环境后,所有签名全错。现象是同一套代码在两个环境表现完全不同。原因是密钥和商户号写在配置里,切环境时只改了网关地址没改密钥。解决方式是把partner、key、网关地址三个值绑定成一组配置,切环境时整组切换,不要单改其中一项。我自己是直接用一张表存环境配置,连接时读对应行,比散落在代码里的常量靠谱。
6. 二次开发改造:给这条链路补上日志、对账和多通道切换
这套接口跑起来只是开始,真正上线之后你会发现缺的东西不少。我一般会在原有基础上做三件事,改动都不大,但能把运维成本降下来。
第一件是原始报文落盘。验签前后各记一条,按日期建目录,文件名带订单号和交易号:
<?php // 回调入口最前面加,失败也要能查到网关到底发了什么 function log_notify($tag, $data) { $dir = '/data/paylog/' . date('Ym') . '/' . date('d'); if (!is_dir($dir)) { mkdir($dir, 0755, true); } $line = date('Y-m-d H:i:s') . "\t" . $tag . "\t" . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n"; file_put_contents($dir . '/notify.log', $line, FILE_APPEND | LOCK_EX); } log_notify('recv', $_POST); // 收到就记 log_notify('done', array('sn' => $order_sn, 'result' => 'success'));按月分目录是为了避免单文件无限增长,出问题按时间找就行。这一步看着土,但每次出事我第一个打开的都是它。
第二件是对账脚本。网关侧有交易明细,本地有pay_log,每天跑一次比对:
| 差异类型 | 判断条件 | 处理方式 |
|---|---|---|
| 网关有、本地无 | 交易号在本地查不到 | 人工核实后补单 |
| 本地有、网关无 | 本地pay_log有记录但网关无此单 | 重点核查,可能是伪造 |
| 金额不一致 | 两边的total_fee转字符串后不等 | 立即告警,冻结发货 |
对账脚本不需要多复杂,一个 PHP CLI 文件,查出当天的pay_log,逐条去网关接口查一次状态,把差异写到一张pay_diff表里。这一步能把"用户说付了但没记录"这类扯皮彻底消灭。
第三件是多通道切换。免签约这套适合小额,大额还是得走正规通道。改造思路是在ecs_payment表里保留多个pay_code,结算页根据订单金额或用户组决定展示哪几个,build_form里把公共的签名逻辑抽成一个独立函数,各通道只负责自己的参数组装和验签规则。这样新增一个通道就是新增一个目录文件的事,不用动主流程。
验证改造效果最直接的办法是手工重放回调。用curl把一份真实报文打到本地接口,看订单状态变化、日志是否完整、重复打第二次会不会重复改单:
# 重放一次回调,验证幂等和日志 curl -X POST "http://测试域名/notify.php" \ -d "out_trade_no=20240101123456" \ -d "total_fee=19.90" \ -d "trade_status=TRADE_SUCCESS" \ -d "trade_no=2024010122001" \ -d "sign=计算出的签名" \ -d "sign_type=MD5" -i第一次返回success且订单变已付款,第二次返回success但订单时间和日志表没有新增记录,才算幂等做对了。
我现在的习惯是,任何一个新的支付通道接进来,先在本地把回调重放跑三遍:一遍正常、一遍改金额、一遍改签名,三遍结果都符合预期才允许上测试环境。从那以后我每次接支付回调,都强制走一遍这个重放流程,不管多急的单子。希望帮到你。
本文还有配套的精品资源,点击获取