大量基于开源程序二次开发的发卡系统,早期接入epay支付网关均采用V1协议完成对接。随着服务商逐步开放epay V2新版接口,不少开发人员在升级适配时遇到各类隐性故障:验签持续报错、异步通知无法正常触发、下单接口路由404、订单状态同步错乱。网上多数教程仅提供单一版本基础接入示例,很少横向对比两套协议底层设计区别,缺少完整代码对照、RSA密钥生成、标准回调回执这类实操内容,也缺少新旧版本并行兼容的完整实现思路。
发卡网接入epay和普通商城系统接入存在明显区别。发卡程序对回调时序、幂等防重复发货、订单原子锁定的要求更高,协议版本改动不只是修改接口地址,签名体系、公共入参、回调返回规范全部发生变更。如果只是简单替换网关地址,没有完整梳理协议差异进行适配改造,上线后极易出现重复发货、订单滞留、支付成功却无法自动发卡等业务故障。
本文全程围绕发卡网接入epay场景,完整对比epay V1、V2两套协议底层架构差异,附上PHP核心签名与验签代码示例,补充RSA密钥生成、JSON报文签名、回执标准、退款接口幂等、双协议商户号限制等实操细节,梳理适配改造过程中必须修改的代码模块,同时讲解双协议兼容层的设计逻辑,方便开发者实现平滑灰度升级,避免全量切换带来的业务中断风险。
一、epay V1与V2协议整体架构变更说明
epay V1协议属于早期设计方案,整体架构偏向表单提交模式,核心接口以submit.php、mapi.php作为入口,适配早期各类PHP开源建站程序。整套协议以MD5签名作为安全校验核心,接口设计轻量化,扩展能力有限,仅能完成基础下单、订单查询能力,原生不支持标准化退款、代结算接口规范。
epay V2协议进行了架构重构,采用REST风格路由设计,拆分出独立的创建订单、订单查询、退款接口,统一接口前缀。安全层面放弃MD5哈希签名,整体切换为RSA非对称签名机制,同时新增时间戳timestamp公共参数,用于防范重放攻击,整体安全性更强,可扩展能力更高。
落实到发卡网接入epay场景,架构变化带来最直观的影响:原有封装好的支付请求函数、回调验签函数无法直接复用。很多开源发卡程序的epay支付插件,底层代码完全基于V1协议编写,一旦切换V2,需要重写请求组装、签名生成、回调验签三段核心逻辑,并非修改配置文件内的网关地址即可完成升级。
两套协议并非简单迭代兼容,属于破坏性变更,不存在直接向下兼容。这也是很多开发者升级实战经验的核心根源,误以为接口只是地址更换,忽略签名算法、必填参数、服务端回执格式的改动。
二、核心接口路由与请求入口差异对比
接口入口是两套协议最直观的区别,也是适配改造第一步需要处理的内容。
| 功能 | epay V1协议入口 | epay V2协议入口 |
|---|---|---|
| 页面跳转收银台下单 | /submit.php | /api/pay/submit |
| API接口创建支付订单 | /mapi.php | /api/pay/create |
| 订单状态查询 | /api.php | /api/pay/query |
| 退款操作接口 | 无统一标准接口,服务商自定义实现 | /api/pay/refund 标准化接口 |
V1协议中,submit.php支持GET、POST两种提交方式,多数发卡程序采用POST表单提交,直接跳转至收银页面。mapi.php多用于后端服务端请求,获取支付链接、二维码地址,适配站内弹窗扫码场景。所有交互接口分散独立,没有统一路由前缀,后期扩展新功能时容易出现接口管理混乱。
V2协议统一使用/api/pay/作为基础路由前缀,功能接口按业务动作拆分,每个接口职责单一。在发卡网接入开发中,如果程序内置扫码支付、页面跳转收银两种模式,需要分别调整两套模式对应的请求入口,同时修改请求头、数据提交格式。部分V1程序使用application/x-www-form-urlencoded表单提交,升级V2之后,部分服务商要求传递JSON格式报文,这一点同样需要在适配改造时处理。
针对多渠道网关配置的发卡系统,后台配置项同样需要改造。原有配置仅填写基础网关域名,升级V2后,程序需要区分V1完整入口地址、V2完整接口地址,兼容层需要根据商户选择的协议版本路由到不同接口。
三、签名算法、密钥体系差异,附带核心PHP代码示例
签名机制是epay V1、V2协议改动最大的部分,也是发卡网接入epay升级过程中故障最多的模块。同时V2存在两种主流签名规范:一种对参数键值对排序拼接后签名,另一种直接对完整JSON报文签名;签名摘要算法主流为SHA256withRSA,少量老旧服务商沿用SHA1withRSA,对接前必须确认服务商文档。
3.1 epay V1 MD5签名生成示例
/** * epay V1 MD5 生成签名 * @param array $params 请求参数 * @param string $key 商户密钥 * @return string */ function epay_v1_make_sign(array $params, string $key): string { // 剔除sign、空值参数 unset($params['sign']); $params = array_filter($params, function ($val) { return $val !== '' && $val !== null; }); // 按键名ASCII升序排序 ksort($params); $str = urldecode(http_build_query($params)) . $key; return md5($str); } /** * epay V1 回调验签 */ function epay_v1_verify_sign(array $notify, string $key): bool { $sign = $notify['sign'] ?? ''; unset($notify['sign']); $calcSign = epay_v1_make_sign($notify, $key); return strtolower($calcSign) === strtolower($sign); } epay V1协议采用MD5对称签名。商户持有商户密钥,请求时按照固定规则拼接参数,拼接末尾附加密钥后进行MD5哈希得到sign签名值。服务商回调通知商户服务端时,商户使用同一套密钥,按照相同规则拼接回调参数,再次生成签名和回调传入的sign对比,完成验签。整个流程只需要维护一组密钥,开发上手简单,但对称密钥存在泄露风险,一旦密钥泄露,攻击者可以伪造回调通知,实现恶意自动发卡。
3.2 epay V2 RSA签名生成与验签示例(参数拼接模式,SHA256withRSA)
/** * epay V2 RSA 私钥签名(参数键值对排序拼接模式) * @param array $params * @param string $privateKey 商户私钥 * @return string */ function epay_v2_make_sign(array $params, string $privateKey): string { unset($params['sign']); $params = array_filter($params, function ($val) { return $val !== '' && $val !== null; }); ksort($params); $data = urldecode(http_build_query($params)); $res = openssl_pkey_get_private($privateKey); openssl_sign($data, $sign, $res, OPENSSL_ALGO_SHA256); openssl_free_key($res); return base64_encode($sign); } /** * epay V2 公钥验签(服务商回调) */ function epay_v2_verify_sign(array $notify, string $publicKey): bool { $sign = base64_decode($notify['sign'] ?? ''); unset($notify['sign']); $notify = array_filter($notify, function ($val) { return $val !== '' && $val !== null; }); ksort($notify); $data = urldecode(http_build_query($notify)); $res = openssl_pkey_get_public($publicKey); $verify = openssl_verify($data, $sign, $res, OPENSSL_ALGO_SHA256); openssl_free_key($res); return $verify === 1; } 3.3 epay V2 完整JSON报文签名模式(另一种主流实现)
部分服务商不采用参数键值对拼接签名,而是要求对完整JSON报文整体签名。这种模式下,待签字符串是标准JSON字符串,不再进行ksort和http_build_query拼接。需要特别注意:JSON编码时必须保持字段顺序和请求报文完全一致,不能额外添加空格、换行,否则验签必然失败。
/** * epay V2 RSA 私钥签名(完整JSON报文签名模式) */ function epay_v2_json_make_sign(array $params, string $privateKey): string { unset($params['sign']); // 关键:不排序、不转义斜杠、不转义中文,保持和提交报文完全一致 $data = json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $res = openssl_pkey_get_private($privateKey); openssl_sign($data, $sign, $res, OPENSSL_ALGO_SHA256); openssl_free_key($res); return base64_encode($sign); } /** * JSON模式验签:直接使用原始请求体作为待签字符串 */ function epay_v2_json_verify_sign(string $rawBody, string $sign, string $publicKey): bool { $sign = base64_decode($sign); $res = openssl_pkey_get_public($publicKey); $verify = openssl_verify($rawBody, $sign, $res, OPENSSL_ALGO_SHA256); openssl_free_key($res); return $verify === 1; } 3.4 RSA密钥对生成与PKCS#1/PKCS#8格式兼容
本地通过OpenSSL命令生成标准PEM格式密钥对,注意不要携带多余换行、空格上传后台,部分面板会自动截断密钥:
# 生成私钥(默认PKCS#1格式) openssl genrsa -out private.key 2048 # 由私钥导出公钥 openssl rsa -in private.key -pubout -out public.key
这里有一个容易踩的格式坑:OpenSSL默认生成的私钥是PKCS#1格式(头部为-----BEGIN RSA PRIVATE KEY-----),而部分Java工具、在线密钥生成器导出的是PKCS#8格式(头部为-----BEGIN PRIVATE KEY-----)。PHP的openssl_pkey_get_private()对两种格式都能识别,但部分老旧PHP版本、受限虚拟主机环境加载PKCS#8会失败,遇到私钥加载报错时,可以执行格式转换命令:
# PKCS#1 转为 PKCS#8 openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.key -out private_pkcs8.key # PKCS#8 转回 PKCS#1 openssl rsa -in private_pkcs8.key -out private_pkcs1.key
落实到发卡程序改造,原有整套MD5签名拼接、验签函数全部废弃,必须新增RSA加解密、签名验签工具类。对于老旧开源发卡程序,底层框架没有内置openssl扩展调用逻辑,部署环境缺少openssl组件,会直接导致签名生成失败,这是适配改造前期需要提前评估的运行环境依赖。
四、公共请求参数、异步回调规范与标准回执示例
epay V1协议公共参数以pid、out_trade_no、money、name、notify_url、return_url作为核心基础字段,没有强制时间戳参数。服务商回调通知商户服务器时,回传交易号、订单号、支付金额、sign等字段,商户校验签名之后,直接执行业务逻辑,完成订单发货。回调成功后,商户仅需要输出字符串success告知服务商停止重试。
epay V2协议在原有基础字段之上,新增timestamp作为必传公共参数,用于防重放校验。商户服务端接收回调之后,除完成RSA验签,还需要判断当前服务器时间和回调携带的timestamp差值,超过阈值(常规建议300秒)则直接拒绝处理。回调回执部分,部分服务商不再支持单纯返回success字符串,要求返回标准化JSON结构,标识处理结果。 V2标准回调回执示例:
// 处理成功 {"code": 200, "msg": "success"} // 处理失败 {"code": 400, "msg": "verify fail"} 这一处改动对发卡系统影响很大。原有回调控制器逻辑:验签通过 → 判断订单未发货 → 锁定订单 → 执行发卡 → 更新订单状态。升级V2之后,业务前置新增timestamp校验逻辑,如果程序没有实现,攻击者可以抓取历史回调报文反复请求接口,造成多次自动发货。
同时需要区分同步返回页面跳转和异步服务通知两套逻辑。V1协议return_url同步回跳参数和notify_url回调参数结构基本一致,V2部分服务商做了字段区分,直接复用同一套解析函数会出现字段缺失报错。
五、epay V2退款接口基础调用差异与回调幂等处理
V1没有统一退款接口,各家服务商自行开发,入参、签名规则各不相同,很难封装通用逻辑;V2提供标准化 /api/pay/refund 退款接口,同样遵循整套RSA签名、timestamp校验规范。
退款接口常用入参:商户号pid、out_trade_no(商户原订单号)、refund_no(商户退款单号)、refund_money退款金额、timestamp、sign。调用流程和创建订单相似,组装参数排序、私钥签名后POST提交JSON报文;退款结果同样支持异步回调通知商户,商户需要新增退款回调处理分支,区分支付回调与退款回调,避免误触发发卡逻辑。
对于发卡网业务,退款场景一般对应卡密回收、订单作废,开发时需要在业务层增加库存回滚、卡密失效逻辑,不能只完成接口调用。
5.1 退款回调必须做幂等去重
退款回调和支付回调一样,服务商存在重复重试机制,如果不做幂等控制,会出现多次回收卡密、多次回滚库存的严重业务故障。退款回调幂等处理建议:
- 数据库refund_no退款单号建立唯一索引,重复回调直接拦截;
- 订单状态使用状态机单向流转,仅允许【已支付 → 退款中 → 已退款】,已退款状态再次收到回调直接跳过业务处理;
- 退款业务操作(卡密失效、库存回滚、余额返还)放在数据库事务内执行,配合行级锁防止并发重复处理;
- 回调处理完成后记录回调日志,包含原始报文、处理结果,方便后续排查重复回调问题。
六、发卡网接入epay V1迁移V2适配改造模块拆解
完整适配改造不能只修改支付提交代码,需要分层修改配置层、请求封装层、验签工具层、回调业务层、订单查询模块,五个模块联动调整。
配置层改造:后台支付通道配置表单,增加协议版本选择项,区分V1 MD5密钥配置、V2公私钥配置存储。数据库支付通道表新增version字段,标记当前通道使用的协议版本,兼容新旧两套配置,实现多网关不同协议并存。
请求封装层改造:抽象统一支付请求类,内部增加版本分支判断。如果是V1协议,执行表单参数拼接、MD5签名;如果是V2协议,组装标准请求报文,使用商户私钥生成RSA签名,携带timestamp参数,调用新版接口地址。对外暴露统一创建订单方法,上层业务代码无需感知底层协议差异,降低业务层改动量。
验签工具层改造:独立封装工具类,分别实现MD5验签、RSA验签两套方法,根据通道配置的协议版本自动选用对应的校验逻辑。工具类内部统一处理参数过滤、ASCII排序逻辑,避免业务代码多处重复编写拼接规则,降低后期维护成本。
回调业务层改造:在原有验签逻辑之前,增加timestamp时间校验分支。针对V2协议回调,校验时间差值;V1协议保持原有逻辑不变。验签通过之后,继续执行订单幂等判断、库存锁定、自动发卡逻辑,这部分业务逻辑本身不需要大幅改动,做到支付协议和业务解耦。
订单查询模块改造:V1、V2查询接口入参、返回字段结构不同,需要封装统一查询结果转换器,将两套接口返回的数据映射为程序内部统一订单状态枚举,上层业务不需要区分协议版本判断支付状态。
七、双协议并行兼容层分发伪代码与商户号并行限制
class EPayAdapter { public function createOrder(array $orderInfo, array $channelConfig) { if ($channelConfig['version'] === 'v1') { return $this->v1Driver->create($orderInfo, $channelConfig); } elseif ($channelConfig['version'] === 'v2') { return $this->v2Driver->create($orderInfo, $channelConfig); } throw new Exception("协议版本不支持"); } public function notifyVerify(array $notifyData, array $channelConfig): bool { if ($channelConfig['version'] === 'v1') { return $this->v1Driver->verifyNotify($notifyData, $channelConfig); } elseif ($channelConfig['version'] === 'v2') { // V2额外校验timestamp差值 if (abs(time() - $notifyData['timestamp']) > 300) { return false; } return $this->v2Driver->verifyNotify($notifyData, $channelConfig); } return false; } } 对于正在运营中的发卡站点,不建议一次性全量切换epay V2协议,一旦适配存在BUG,会直接造成订单卡顿、无法发卡。最优方案是搭建简易兼容层,实现V1、V2协议并行运行,灰度切换流量,验证稳定之后逐步下线V1通道。
兼容层核心设计思路:面向业务层提供统一支付网关接口,兼容层内部作为中间转换层,根据通道配置的协议版本,分发至对应的V1或V2底层实现。上层创建订单、查询订单、接收回调的业务代码保持不动,所有协议差异隔离在兼容层内部。
兼容层需要实现参数适配器:将程序内部标准订单对象,转换成epay V1表单参数,或者epay V2 JSON请求报文;同时实现响应适配器,将V1、V2不同格式的接口返回数据,统一转换成内部标准返回结构。
灰度切换策略可以按商户通道维度控制:新增测试通道使用V2协议进行联调测试,原有线上运行通道继续保留V1协议。完整覆盖正常下单、回调通知、异常订单查询、超时关闭订单等场景测试无误后,再逐步迁移正式业务通道。同时保留完善日志记录,兼容层完整记录原始请求报文、原始回调报文、签名计算结果,方便出现验签异常时快速定位问题。
兼容层设计的额外收益:后续如果epay推出更高版本协议,只需要新增底层协议实现,不需要大面积修改发卡主业务代码,降低后续版本迭代改造工作量。
八、适配改造高频隐性问题成因分析
第一类高频问题:RSA验签一直失败。多数情况是公私钥复制携带换行空格、公钥私钥混用、参数拼接排序规则和服务商文档不一致、摘要算法SHA1与SHA256混淆、签名模式判断错误(参数拼接模式误用JSON模式)。MD5签名时密钥拼接位置错误也会出现同类现象,升级过程中建议打印完整待签字符串,和服务商示例报文逐字符对比排查。
第二类问题:支付成功但是无法自动发卡。排除验签失败之后,重点排查回调回执格式。V2部分服务商要求返回JSON结构,如果程序依旧返回纯文本success,服务商判定回调处理失败,持续重试回调,严重时触发多次发货。同时注意回执字段名差异,code/status/retCode不能混用。
第三类问题:V2下单请求直接返回接口错误。重点检查timestamp是否正确生成、时区是否统一使用UTC时间,部分服务商严格校验时间格式,本地服务器时区错乱会直接判定请求非法。
第四类问题:切换V2之后订单查询接口无法获取状态。V2查询接口为POST请求,部分老旧代码沿用V1 GET请求方式提交,请求方法不匹配直接接口报错,同时查询接口必填参数名称存在差异,需要对照文档核对字段。
第五类问题:OpenSSL扩展未启用或私钥格式不兼容。很多虚拟主机默认关闭openssl,调用RSA签名函数直接报错;部分环境PKCS#8格式私钥加载失败,转换为PKCS#1格式即可解决。部署前需要查看phpinfo确认扩展启用,提前测试私钥加载。
第六类问题:同一商户号双协议混用。部分开发者误以为同一个pid可以同时跑V1和V2,配置后出现一边能下单一边验签失败,实际上需要分别申请两套商户号分开配置。
九、发卡网接入epay版本升级后的回归测试重点场景
适配改造完成之后,不能直接上线,针对发卡业务特性,需要覆盖多组测试场景验证两套协议兼容逻辑是否正常运行。
基础正向场景:正常提交订单、完成支付、异步回调自动发卡、同步页面回跳展示订单结果。
异常边界场景:支付中途关闭页面、超时未支付订单、服务商多次重复回调、网络中断造成回调丢失、主动调用订单查询同步状态。
协议兼容场景:同一个程序内,部分通道使用epay V1,新增通道使用epay V2,两套通道同时下单、回调互不干扰。注意两套通道必须使用不同商户号。
退款业务场景:发起退款、退款异步回调、重复退款回调幂等拦截、库存与卡密状态变更。
签名兼容场景:分别测试参数拼接签名模式、JSON报文签名模式,确认当前服务商使用的模式和代码实现一致。
安全测试场景:篡改回调金额、伪造回调请求、重放历史回调报文,验证timestamp校验、签名校验逻辑是否生效,拦截非法请求,避免非授权自动发货。
整套回归测试全部通过之后,再逐步切换线上业务通道,同时持续观察一段时间日志,监控验签异常、回调报错数量,及时处理适配遗漏的边界场景。