简介:面向 ECShop V2.7.3 商城系统的支付宝免签约即时到帐接口,适合没有支付宝签约资质、又希望快速接入即时到账支付的中小站长与开发者使用。资源包共 7 个文件,以 6 个 PHP 支付模块、回调接收及中英文语言文件为主,另附 1 个 TXT 说明文档,压缩包约 7KB,体积轻量,便于部署和二次查看。其核心价值在于补齐 ECShop 后台的支付方式配置,提供到账通知接收、支付模块挂载与多语言适配等基础能力,并配有安装、卸载说明和辅助软件配置指引,可帮助读者理解免签约支付接口在商城中的接入方式、通知地址设置思路与常见维护路径。目前已有 318 人学习下载,适合需要低成本实现支付宝即时到账、研究 ECShop 支付扩展结构及接口回调流程的站长和技术人员参考。
1. 老商城接支付:免签约即时到账为什么还是小商户的性价比解法
上周帮人救一个跑在老虚拟主机上的 ECShop 站,老板的原话是「客户下单了但钱收不到,后台订单一直挂着待付款」。翻后台一看,支付方式里配置了支付宝免签约即时到账接口,partner 和 key 都填了,唯独 notify_url 指到了一个需要登录才能访问的路径,回调被 302 到首页,订单状态永远停在待付款。这类问题几乎占了老商城支付故障的一半。
ECShop 2.7.3 这套代码放到今天看确实老,但它对支付插件的抽象做得干净:一个 PHP 类、几个固定方法、一个回调入口文件,就能把即时到账跑通。免签约的好处是不用走企业资质流程,个人收款账号填上合作身份者 ID 和 MD5 密钥就能收钱,对刚起步的小店足够用。适合手上维护着老商城、想低成本把收款链路补齐的开发者;如果你的项目是全新架构,那另说。
2. 插件落点:ECShop 支付模块的目录结构与后台挂载
老商城的支付体系不是靠配置文件注册的,而是靠目录扫描。你把一个符合命名规范的 PHP 类扔进指定目录,后台支付方式列表里就会多出一行。理解这套约定,比读十页文档都管用。
2.1 一个支付插件必须放对的三个位置
ECShop 2.7.3 的支付插件由三部分构成,缺一个就跑不起来:
| 路径 | 作用 | 是否必须 |
|---|---|---|
| includes/modules/payment/alipay.php | 支付插件主类,负责参数配置、发起支付 | 必须 |
| languages/zh_cn/payment/alipay.php | 后台配置项和前台显示名称的语言包 | 必须,否则后台配置表单会显示成数组键名 |
| alipay_notify.php(根目录) | 异步通知入口,接收支付平台回调 | 必须,路径可以自定义但要在配置里回填 |
主类文件名决定了get_code()的返回值,也决定了数据库ecs_payment表里pay_code字段的值。老商城加载支付方式时,是遍历includes/modules/payment/目录下所有.php文件,用文件名作为 code 去数据库里查配置。所以文件名一旦改动,历史订单的支付记录就对不上,这是血泪经验——我见过有人把alipay.php改名为alipay_direct.php想「区分新旧版」,结果老订单详情页直接白屏。
语言包文件的结构长这样,键名要和主类里configure()返回的字段一一对应:
<?php // languages/zh_cn/payment/alipay.php $_LANG['alipay'] = '支付宝即时到账'; $_LANG['alipay_desc'] = '无需企业签约,个人收款账号即可使用'; $_LANG['alipay_partner'] = '合作身份者ID'; $_LANG['alipay_key'] = 'MD5密钥'; $_LANG['alipay_seller_email'] = '收款账号'; $_LANG['alipay_notify_url'] = '异步通知地址'; $_LANG['alipay_return_url'] = '同步跳转地址'; $_LANG['alipay_currency'] = '支付货币'; $_LANG['alipay_currency_range'][0] = '人民币';注意最后两行的写法,configure()里如果用了下拉框,语言包里要用xxx_range的数组形式。漏写这个数组,后台配置页会渲染成一个空的下拉,保存时提交空值,密钥就丢了。
2.2 后台配置项怎么变成数据库记录
后台点击「安装」时,ECShop 会调用插件的configure()拿到字段定义,把字段名、类型、当前值序列化后写进ecs_payment表的pay_config字段。这个字段是一段 PHP 序列化字符串,结构大致是:
// ecs_payment 表中 pay_config 反序列化后的结构 array( 0 => array('name' => 'alipay_partner', 'type' => 'text', 'value' => '2088xxxxxxxxxxxx'), 1 => array('name' => 'alipay_key', 'type' => 'text', 'value' => 'xxxxxxxxxxxxxxxx'), 2 => array('name' => 'alipay_seller_email', 'type' => 'text', 'value' => 'shop@example.com'), 3 => array('name' => 'alipay_notify_url', 'type' => 'text', 'value' => 'https://shop.example.com/alipay_notify.php'), );插件运行时通过get_config()把这些值读成关联数组,键名去掉alipay_前缀后使用。很多人在本地调试时直接在代码里硬编码密钥,上线忘了改回来,结果测试环境的密钥跑到生产环境,签名全部失败。常见做法是:configure()里只声明字段,运行时统一走$this->get_config(),绝不硬编码。
这里还有一个容易被忽略的细节——配置里填的 URL 必须是完整地址,带协议头。老商城有些主题会做 HTTPS 跳转,如果填http://而站点强制跳https://,回调会先吃一个 301,部分支付网关的重试逻辑处理不好这种跳转,通知就丢了。我一般直接填https://开头的完整路径,并且用浏览器直接访问一次,确认返回的不是登录页。
2.3 插件类的骨架方法
一个能跑起来的最小插件类,必须实现这几个方法,方法名不能改:
<?php // includes/modules/payment/alipay.php if (!defined('IN_ECS')) { die('Hacking attempt'); } class alipay { // 与文件名保持一致,ECShop 靠它匹配 ecs_payment.pay_code public $code = 'alipay'; public $name = '支付宝即时到账'; public $desc = '免签约即时到账'; // 后台配置表单,返回字段定义数组 public function configure() { return array( array('name' => 'alipay_partner', 'type' => 'text', 'value' => ''), array('name' => 'alipay_key', 'type' => 'text', 'value' => ''), array('name' => 'alipay_seller_email', 'type' => 'text', 'value' => ''), array('name' => 'alipay_notify_url', 'type' => 'text', 'value' => ''), array('name' => 'alipay_return_url', 'type' => 'text', 'value' => ''), ); } // 前台结算页显示的名称 public function get_code() { return $this->code; } public function get_name() { return $this->name; } // 生成支付按钮的 HTML,只负责出按钮,不带业务逻辑 public function get_html() { return '<a href="respond.php?code=alipay&order_sn=' . urlencode($_REQUEST['order_sn']) . '">' . $this->name . '</a>'; } // 真正发起支付的地方,返回跳转地址 public function exec() { return $this->buildRequest(); } }方法职责要分清。configure()只声明字段,get_html()只出 HTML,exec()里才做参数拼装和签名。有些改造版把签名逻辑塞进get_html(),导致用户只是打开结算页就被扣了一次签名计算,虽然不出错,但一旦签名函数里有 curl 请求,页面加载会明显变慢。职责分开,调试时也更容易定位是哪一环挂的。
exec()里能拿到什么?$_REQUEST['order_sn']是订单号,$_REQUEST['order_amount']是金额。但这两个值都是前台传过来的,不能直接信。合格的做法是在exec()内部用order_sn回查一次数据库,拿真实金额,否则有人手动改 URL 里的金额参数,就能用 0.01 元买走一车货——这种漏洞在老商城里不是传说,是真实存在过的。
3. 支付发起:参数拼装、MD5 签名与跳转
签名是这条链路上最容易翻车的一环,因为它不报错,只是默默地让网关返回「验签失败」。搞清楚签的是什么、按什么顺序签、哪些参数不参与签名,后面就顺了。
3.1 请求参数表:少一个字段都签不过
即时到账的请求参数有必填和非必填之分,必填项漏一个,网关会直接拒绝。
| 参数 | 必填 | 说明 |
|---|---|---|
| service | 是 | 固定create_direct_pay_by_user,表示即时到账 |
| partner | 是 | 合作身份者 ID,后台配置里的那串数字 |
| seller_email | 是 | 收款账号,必须和 partner 绑定关系一致 |
| out_trade_no | 是 | 本站订单号,全局唯一,重复会报错 |
| subject | 是 | 订单标题,建议不带&、=、中文括号等符号 |
| total_fee | 是 | 金额,单位元,保留两位小数,最小 0.01 |
| body | 否 | 订单描述,不影响签名正确性 |
| notify_url | 是 | 异步通知完整地址,必须是公网可达 |
| return_url | 是 | 用户付款后浏览器跳回的地址 |
| payment_type | 是 | 固定1,表示商品购买 |
| _input_charset | 是 | 固定utf-8,要和站点编码一致 |
| sign | 是 | 签名结果,32 位小写 |
| sign_type | 是 | 固定MD5 |
subject这个字段坑很多。订单标题通常来自商品名,而商品名里经常有空格、中文全角括号、甚至 emoji。这些字符本身不影响签名,但会经过一次 URL 编码,如果签名时用的是编码后的值,而网关验签时用的是编码前的值,就会对不上。稳妥做法是签名前对subject做一次清理,去掉&、=、#三个字符,长度截断到 128 字节以内。
total_fee用浮点数直接格式化也会出问题。PHP 里0.1 + 0.2不等于0.3,如果订单金额是购物车多项累加出来的,number_format四舍五入偶尔会把0.095变成0.10,而订单表里存的是0.09,两边对不上,回调校验就会失败。我一般统一用sprintf('%.2f', $amount),并且在订单生成时就把金额定死存库,支付时只读不算。
3.2 签名函数的写法
签名规则说起来简单:把所有参与签名的参数按参数名 ASCII 升序排列,拼成k1=v1&k2=v2的形式,末尾直接拼接密钥,做一次 MD5,转小写。但每个环节都有细节。
<?php /** * 生成即时到账请求签名 * @param array $params 业务参数(不含 sign、sign_type) * @param string $key 后台配置的 MD5 密钥 * @return string 32 位小写签名 */ function alipay_sign(array $params, $key) { // 1. 剔除空值和签名相关字段 foreach ($params as $k => $v) { if ($v === '' || $v === null || $k === 'sign' || $k === 'sign_type') { unset($params[$k]); } } // 2. 按参数名 ASCII 升序,注意是 ksort 不是 asort ksort($params); // 3. 拼接,值不做 urlencode,也不做 urldecode $pairs = array(); foreach ($params as $k => $v) { $pairs[] = $k . '=' . $v; } $signStr = implode('&', $pairs); // 4. 末尾直接拼密钥,不要加 & 分隔符 return md5($signStr . $key); }逐段看。第一步剔除空值是硬性要求,网关那边的规则是「参数值为空不参与签名」,你这边如果带着空值一起签,字符串长度就不一样,MD5 结果必然不同。sign和sign_type本身不参与签名,这个在文档里写了但很多人漏看。
第二步用ksort,它对关联数组按键名升序排序,正好对应 ASCII 升序。用asort是按值排序,签出来的结果永远是错的,而且不同订单错得还不一样,排查起来很折磨。
第三步的值不做任何编码转换。这一点最反直觉——最终跳转时 URL 上确实要urlencode,但签名用的是原始值。正确顺序是:先用原始值签名,再把sign塞进参数数组,最后对整个参数数组做一次http_build_query。如果顺序反过来,先编码再签名,验签必挂。
第四步末尾直接拼密钥,中间没有&。有些教程写成$signStr . '&key=' . $key,那是另一套接口的规则,混用就全错了。
3.3 从购物车到网关的完整跳转
exec()里的完整流程,包含回查订单、拼参数、签名、组装跳转地址:
<?php public function exec() { $orderSn = isset($_REQUEST['order_sn']) ? trim($_REQUEST['order_sn']) : ''; if ($orderSn === '') { return array('url' => 'index.php', 'err' => '订单号为空'); } // 关键:金额必须回查数据库,不能信前台传参 $sql = "SELECT order_id, order_sn, order_amount, consignee FROM " . $GLOBALS['ecs']->table('order_info') . " WHERE order_sn = '" . $orderSn . "' AND pay_status = 0 LIMIT 1"; $order = $GLOBALS['db']->getRow($sql); if (empty($order)) { return array('url' => 'index.php', 'err' => '订单不存在或已支付'); } $cfg = $this->get_config(); // 读 ecs_payment.pay_config $params = array( 'service' => 'create_direct_pay_by_user', 'partner' => $cfg['alipay_partner'], 'seller_email' => $cfg['alipay_seller_email'], 'out_trade_no' => $order['order_sn'], 'subject' => mb_substr($order['order_sn'], 0, 60, 'utf-8'), 'total_fee' => sprintf('%.2f', $order['order_amount']), 'notify_url' => $cfg['alipay_notify_url'], 'return_url' => $cfg['alipay_return_url'], 'payment_type' => '1', '_input_charset' => 'utf-8', ); $params['sign'] = alipay_sign($params, $cfg['alipay_key']); $params['sign_type'] = 'MD5'; $gateway = 'https://mapi.alipay.com/gateway.do?'; return array('url' => $gateway . http_build_query($params)); }几个细节值得说。pay_status = 0这个条件很重要,它保证了已支付的订单不会被重复发起支付,算是第一道幂等闸门。subject用订单号而不是商品名,是因为即时到账的标题对用户来说看得见,但老商城的商品名里带特殊符号的概率太高,用订单号最稳,用户也能对上账。sprintf('%.2f', ...)兜住浮点精度,http_build_query负责把整个数组编码成 URL,它会自动对值做urlencode,正好符合「先签名后编码」的顺序。
返回的数组由上层负责跳转,url是目标地址,err非空时走错误提示页。这种返回结构比直接header('Location: ...')好,因为可以统一做日志和异常兜底。
4. 异步通知:notify 验签、订单状态机与幂等
同步跳转只是给用户看的,真正决定订单状态的是异步通知。用户付完款直接关掉浏览器,return_url根本不会触发,只有notify_url会来。所以订单状态更新必须写在通知处理里,return_url那边顶多做个跳转提示。
4.1 通知入口文件的最小结构
alipay_notify.php放在站点根目录,不需要登录态,但必须能加载到 ECShop 的核心库:
<?php define('IN_ECS', true); require dirname(__FILE__) . '/includes/init.php'; require dirname(__FILE__) . '/includes/lib_order.php'; // 通知是 POST 过来的表单,取出全部参数 $params = $_POST; if (empty($params) || empty($params['sign'])) { echo 'fail'; exit; }IN_ECS这个常量是必须的,init.php里会检查它,没定义就die。这也是老商城的一个特点:所有入口文件都得先声明在自己人家里。
通知的参数和请求参数高度重合,多了trade_no(支付平台流水号)、trade_status(交易状态)、notify_id(通知校验 ID)、gmt_payment(付款时间)等。trade_status有两个值需要处理:TRADE_SUCCESS表示支付成功,TRADE_FINISHED表示交易完成(不可退款)。对于即时到账,TRADE_SUCCESS就可以把订单置为已付款,不必等TRADE_FINISHED。
4.2 验签与二次确认
收到通知的第一件事永远是验签,不能因为「地址是私有的」就跳过:
<?php // 1. 本地验签 $sign = $params['sign']; unset($params['sign'], $params['sign_type']); $localSign = alipay_sign($params, $cfg['alipay_key']); if ($localSign !== $sign) { alipay_log('sign mismatch', $params); echo 'fail'; exit; } // 2. 二次确认:拿着 notify_id 回问一次网关 $verifyUrl = 'https://mapi.alipay.com/gateway.do?service=notify_verify' . '&partner=' . urlencode($cfg['alipay_partner']) . '¬ify_id=' . urlencode($_POST['notify_id']); $ch = curl_init($verifyUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); $resp = curl_exec($ch); curl_close($ch); if (trim($resp) !== 'true') { alipay_log('notify verify failed', array('resp' => $resp)); echo 'fail'; exit; }第一段验签把sign和sign_type从参数里摘掉再算,算完比对。第二段二次确认是很多人省掉的一步,但它能挡掉伪造通知——本地密钥万一泄露,光靠验签是挡不住的,回问一次网关才知道这条通知是不是真的由官方发出。
curl超时要设,5 秒够了。有次线上环境没设超时,网关那边慢了一点,通知请求一直挂着,PHP 进程池被占满,整站 502。血泪经验:任何外部 HTTP 调用都必须设CURLOPT_TIMEOUT。
4.3 订单状态机:order_status 和 pay_status 要配对
ECShop 的订单有两个状态字段,order_status管流程(0 待确认、1 已确认、2 已取消),pay_status管钱(0 未付、1 部分付款、2 已付款)。付款成功要一起改:
<?php if ($params['trade_status'] === 'TRADE_SUCCESS' || $params['trade_status'] === 'TRADE_FINISHED') { $outTradeNo = $params['out_trade_no']; $tradeNo = $params['trade_no']; $gmtPayment = $params['gmt_payment']; // 幂等:只有 pay_status = 0 的订单才更新,影响行数为 0 说明已经处理过 $sql = "UPDATE " . $GLOBALS['ecs']->table('order_info') . " SET order_status = 1, pay_status = 2, pay_time = '" . $gmtPayment . "', money_paid = money_paid + '" . sprintf('%.2f', $params['total_fee']) . "', trade_no = '" . $tradeNo . "' WHERE order_sn = '" . $outTradeNo . "' AND pay_status = 0"; $GLOBALS['db']->query($sql); if ($GLOBALS['db']->affected_rows() > 0) { // 只有真正更新了才走后续:写订单日志、加积分、发通知邮件 order_paid($outTradeNo); } echo 'success'; exit; }order_paid()是老商城内置的函数,负责写ecs_order_action日志、给用户加积分、触发邮件通知。它本身有一定的幂等性,但不完全,所以外面要包一层affected_rows()判断。这是整个通知处理里最关键的一行:WHERE pay_status = 0让重复通知天然失效,第二次进来影响行数为 0,后续动作全跳过。
money_paid用的是累加而不是赋值,因为老商城支持分次付款。虽然即时到账一般是一次付清,但用累加更保险。
4.4 返回值的时机
处理成功返回success,任何一步失败返回fail,这两个字符串是网关判断要不要重发的依据。返回fail之后网关会按一定间隔重试,一般会重试若干次。所以临时性故障(比如数据库连接抖动)返回fail让网关重发是对的,但如果是签名不对这种确定性错误,返回fail只会让网关白白重试,日志里会堆满重复记录。
我一般的处理是:验签失败返回fail并记日志(说明配置有问题,需要人介入),业务处理成功返回success,订单不存在返回success(避免无意义重试),代码异常返回fail。
5. 避坑与排查:签名失败、重复回调、金额对不上的五类翻车
这条链路上的故障有个共同特点:表面上都是「支付不成功」,但根因分散在编码、时间、并发、配置四个方向。下面五条是我踩过或者帮人修过的,每条都给出定位方法。
第一条:签名永远对不上,换什么参数都没用。
现象是网关页面直接提示验签失败,本地算出来的签名和网关返回的对比永远不同。原因通常有三个:一是参数值里混进了空字符串参与签名,比如body字段赋值成了'',你以为它会被跳过,但剔空逻辑写成了if (!$v),'0'这种合法值也被剔掉了;二是ksort之后又做了一次array_values重排,顺序全乱;三是密钥从后台复制时带上了首尾空格,MD5 里多一个空格结果就完全不同。解决办法是把签名前的拼接串var_dump出来,和在线签名工具的结果逐字符比对,空格、换行都看得见。我习惯在调试期把$signStr写进日志文件,出问题直接看日志,比猜快十倍。
第二条:后台明明显示已付款,前台订单还是待付款。
现象是支付平台后台能看到交易成功,ECShop 订单列表却是未付款。原因是notify_url不可达,常见情况有三种:地址填的是内网 IP、填的路径需要登录、站点开了强制 HTTPS 但配置里写的 HTTP 导致 301 跳转后 POST 体丢失。定位方法是拿浏览器直接访问notify_url,正常应该返回fail(因为没带参数),如果返回的是登录页、404 页面或者一整段 HTML,就说明路径有问题。另外要检查文件编码,alipay_notify.php如果存成了 UTF-8 with BOM,输出的 BOM 头会在success之前,网关认为返回内容不合法,同样会重发。
第三条:用户收到重复的付款成功邮件,积分加了两遍。
现象是同一个订单触发了多次业务动作。原因是通知处理没有做幂等,网关因为超时或者没收到success会重发通知,每次重发都走一遍order_paid()。解决办法就是前面说的UPDATE ... WHERE pay_status = 0加affected_rows()判断。还有一种更隐蔽的情况:数据库主从延迟,UPDATE打在主库,紧接着的SELECT走了从库,读到的还是旧状态,判断失效。老商城一般不带读写分离,但如果改造过就要注意,读订单状态时强制走主库。
第四条:小额订单金额对不上,0.1 元变成 0.09 元。
现象是支付平台收到的金额和订单金额差一分钱。原因是浮点运算精度,购物车里多项小计累加,round和number_format混用,四舍五入的方向不一致。解决办法是金额在订单生成时就sprintf('%.2f')定死存库,支付时直接读这个字段,不再做任何运算。另外核对一下total_fee的单位,即时到账是元不是分,如果把分当成元传过去,100 元的订单会变成 1 元,这种错误在测试环境用小额订单根本发现不了。
第五条:中文商品名导致通知里的 subject 乱码。
现象是通知日志里subject显示成一堆问号或者乱码,但不影响支付本身。原因是_input_charset填的和实际编码不一致,或者数据库连接编码不是 utf8。解决办法是统一全链路 UTF-8:_input_charset填utf-8,文件存 UTF-8 无 BOM,数据库表和连接都用 utf8。排查时看ecs_order_info里order_sn对应的记录有没有乱码,如果数据库里就是乱的,那问题在写入端,不在通知端。
6. 二次开发:日志、幂等、对账三个改造点
原版能跑通,但离「敢放在生产环境」还差一层。我在老商城上做支付改造,固定加三样东西,缺一样晚上都睡不踏实。
第一样是支付日志表。不要往文件里写,文件在多机部署时会散落,查起来要登录好几台机器。建一张ecs_alipay_log,字段就四个:id、order_sn、direction(request/notify)、content(JSON)、created_at。写日志用INSERT,不要用UPDATE,避免并发锁。查问题时一条 SQL 就能把一个订单的完整链路捞出来:
SELECT direction, content, created_at FROM ecs_alipay_log WHERE order_sn = '20240520123456' ORDER BY id ASC;第二样是幂等的第二道闸。WHERE pay_status = 0解决的是重复通知,但解决不了「同一个订单开了两个支付窗口、用户都付了」这种情况。极端场景下用户可能真的付两次,这时候需要在通知里比对total_fee和订单的order_amount,不一致就记异常日志并人工介入,不要自动改状态。我一般会加一个金额校验:
<?php $expect = sprintf('%.2f', $order['order_amount']); $actual = sprintf('%.2f', $params['total_fee']); if ($expect !== $actual) { alipay_log('amount mismatch', array('expect' => $expect, 'actual' => $actual)); echo 'success'; // 返回 success 停止重试,转人工 exit; }这里返回success而不是fail,是有意的:金额不一致重试多少次都不会变对,让它一直重发只会污染日志。
第三样是每日对账。老商城没有自动对账,全靠人工翻支付平台后台。我的做法是写个 CLI 脚本,每天凌晨跑一次,把前一天的订单和支付平台的账单做比对,输出三类异常:平台有本站无(可能是伪造)、本站有平台无(可能是假成功)、金额不一致。脚本本身不复杂,关键是必须能重复跑,因为对账脚本最怕的就是跑一半挂了,重跑时又把已经处理过的数据算一遍。
| 改造点 | 解决的问题 | 落地成本 |
|---|---|---|
| 日志表 | 故障定位靠猜 | 一张表 + 两处写入 |
| 金额校验 | 重复付款、改价攻击 | 十行判断 |
| 每日对账 | 静默丢单 | 一个 CLI 脚本 |
有个细节值得单独说:日志里的content字段一定要脱敏,把key、sign这类字段替换掉再入库。我见过有人把完整通知参数原样存库,结果数据库备份被拿走,密钥直接泄露。
从那以后我每次接这类老支付接口,都强制走一遍「浏览器直接访问 notify_url → 看返回是不是 fail → 查日志表有没有记录」这三步,不管代码看着多顺眼。很多问题不是代码写错,是配置和环境的错位,只有实际跑一遍才知道。希望帮到你。
本文还有配套的精品资源,点击获取