做PHP开发这些年,接支付接口算是最常见的需求之一。微信、支付宝的SDK文档满天飞,教程一搜一大把,但轮到银行系支付——尤其建行的H5网页支付,网上能查到的靠谱资料少得可怜,官方文档写得又绕,字段命名也不按主流套路来。去年我正好给一个商城项目对接了建行H5支付,从入网申请、密钥配置到联调上线,全程踩了一遍坑,今天把完整的对接过程整理出来,给后面要做同样事情的朋友省点时间。
这篇文章会覆盖建行H5网页支付从零到上线的完整流程:业务场景分析、密钥准备、下单接口对接、签名验签、异步回调处理、订单查询对账,以及我实际联调中遇到的各种坑。做电商、做扫码点餐、做知识付费的PHP同学都可以参考,代码是基于PHP 7.4+写的,ThinkPHP和原生PHP都能直接平移。
1. 对接前先搞清楚业务逻辑
1.1 建行H5支付到底适合什么场景
建行的H5网页支付,本质上就是用户在手机浏览器里打开你的商城页面,下单后选择建行卡支付,页面跳转到建行收银台完成付款,支付完成后自动跳回你的页面。它不是App内唤起SDK那种方式,也不是扫码枪扫用户的付款码,而是纯网页跳转。
所以它适用的场景很明确:
- 微信公众号/H5商城里的建行卡支付
- 手机网页端的PC商城兜底支付方式
- App内嵌WebView加载H5页面时的支付通道
有些项目为什么非得用建行而不是微信支付宝?我碰到的情况是客户本身就是建行的对公户,走建行支付手续费有优惠政策;还有一类是B2B商城,采购方习惯走企业网银或对公账户付款,建行H5能直接满足这个需求;另外就是某些行业监管要求资金必须走银行通道,不能走第三方支付机构。总之,需求端是真实存在的,而且银行系支付接口对接的复杂度远高于微信支付宝,市面上会做的人不多,掌握了也算一门手艺。
1.2 建行H5支付的整体调用链路
我第一次对接建行支付时,光想当然地按微信支付的思路去理解,结果绕了不少弯路。建行H5支付的调用逻辑大概是这样的:
- 用户在你的H5商城提交订单,后端PHP生成商户订单号,组装支付请求参数
- 后端用商户私钥对请求参数做RSA签名,然后把参数以表单自动提交的方式POST到建行收银台网关
- 用户在建行收银台输入银行卡信息、验证码,完成支付
- 建行收银台同步跳转到你指定的return_url,告诉用户支付结果
- 同时建行服务器向你的异步通知地址notify_url发POST回调,携带支付结果和签名
- 你的后端收到回调后,用建行公钥验签,验签通过再校验金额、订单号,更新订单状态,返回应答
这里和微信支付最大的区别有两点:第一,建行同步跳转(return_url)和异步回调(notify_url)是两条独立的通知渠道,以异步回调为准;第二,建行要求商户必须对接支付结果查询接口,因为回调有可能丢,只能在支付后主动查单去对账。
另外还要注意,建行的H5支付接口文档里,经常会出现"商户柜台支付""移动网页支付""龙支付"几个概念容易混淆。我这次对接的H5网页支付,是建行"聚合支付"体系下的"手机网站支付"服务,它的网关地址和参数格式跟老一代的B2C网银支付、龙支付收款码都不太一样。所以你在申请的时候一定要跟建行客户经理确认清楚开通的是哪一个产品。
2. 入网申请与密钥准备
2.1 商户号申请和产品开通流程
建行支付不像微信支付那样全线上申请,它是半线下的。你需要先有建行的对公账户,然后找开户行的客户经理提接入申请,或者直接在建行开放平台注册。
具体流程我们实操下来是这样的:
- 准备好营业执照、法人身份证、对公账户信息、网站备案信息(如果你用H5支付,域名必须ICP备案)
- 联系开户行客户经理,说明要开通"商户支付"或"聚合支付-手机网站支付"产品,提交资料
- 银行审核通过后,会给你一份《商户服务协议》和一份《接口对接说明》,里面有商户号和初始密钥信息
- 登录建行开放平台(open.ccb.com),在"商户服务-商户服务管理"里配置回调地址、商户公钥/私钥
这里有个坑要提前说:建行的产品线太复杂了,客户经理有时候自己都搞不清楚你该开通哪一类。一定要把"H5网页支付"这个需求明确提出来,如果你的场景是手机网页,就直接说"手机网站支付",如果你的场景是App内嵌H5,跟银行确认一下是否需要单独开通移动App支付产品。我遇到过不少开发者,申请的时候没讲明白,下来对接文档一看,接口和参数完全对不上,又回去重新申请,白白浪费了两周时间。
2.2 密钥格式与生成工具
建行支付接口的加密体系用的是RSA非对称加密。商户自己生成一对公私钥,公钥上传给建行,私钥自己保存在服务器上用于签名;同时建行会把自己的公钥提供给你,用于验签。这个流程和微信支付类似,但具体实现细节上有区别:
- 建行开放平台生成的密钥,有的是PKCS1格式的PEM,有的是PKCS8格式,还有一些老的接口文档会让你用商户柜台去下载一个后缀为.cer的证书文件
- PHP的openssl扩展处理不同格式的密钥时,函数调用会有差异,很多人就是栽在这个细节上
- 建议你把商户私钥统一转换成PKCS8格式的PEM文件,建行公钥如果拿到的是.cer证书,可以用openssl命令转成PEM
密钥生成的时候,如果你用openssl命令生成(这是最常见的做法):
# 生成RSA密钥对,2048位 openssl genrsa -out merchant_private.pem 2048 # 从私钥中提取公钥 openssl rsa -in merchant_private.pem -pubout -out merchant_public.pem # 如果你的私钥在其他平台生成的是PKCS1格式,转换为PKCS8 openssl pkcs8 -topk8 -inform PEM -in pkcs1_private.pem -outform PEM -nocrypt -out merchant_private_pkcs8.pem # 如果建行给的是.cer证书,提取公钥 openssl x509 -inform DER -in ccb_public.cer -pubkey -noout > ccb_public.pem密钥位数建议直接上2048位,虽然1024位也能通过验签,但银行系统这几年在逐步升级安全策略,我听说有商户用1024位密钥在换签的时候被拒了。既然新项目,一步到位比较省心。
2.3 必要的PHP环境检查
对接之前,先确认你的PHP环境支持openssl扩展和curl扩展。大部分PHP环境默认都开着,但也遇到过极简安装的情况。写个命令检查一下:
php -m | grep openssl php -m | grep curl另外,你的服务器要能访问建行的支付网关域名,这个一般没问题,但如果你在开发机上模拟测试,要确认开发机能正常外网访问。我习惯把所有接收回调的接口先用内网穿透工具暴露到公网,方便本地调试,后面会专门讲这个。
3. 核心接口对接实操
3.1 支付下单请求的参数组装
建行H5支付的支付下单接口,官方文档里一般叫"手机网站支付",请求方式是表单POST到建行收银台。参数以键值对的方式传递,必须包含签名字段。不同版本的接口文档字段名略有出入,但核心字段是稳定的。我这里按实际项目用到的字段整理,你对接的版本若字段名有差异,以建行开放平台最新文档为准。
| 参数名 | 含义 | 是否必填 | 说明 |
|---|---|---|---|
| MERCHANTID | 商户号 | 是 | 建行分配的唯一商户标识 |
| ORDERID | 商户订单号 | 是 | 唯一,不能重复,建议不要用自增ID |
| PAYMENT | 支付金额 | 是 | 单位是元,保留两位小数,比如10.00 |
| CURCODE | 币种 | 是 | 01表示人民币 |
| TXCODE | 交易码 | 是 | 手机网站支付有固定值,具体看文档 |
| REMARK | 备注 | 否 | 会透传到建行后台,可放订单标题 |
| NOTIFYURL | 异步通知地址 | 是 | 公网可访问,返回"Success"才算处理成功 |
| RETURNURL | 同步跳转地址 | 是 | 支付完成后用户浏览器跳转的地址 |
| SIGNINFO | 签名串 | 是 | 对上述字段做签名后的值 |
组装参数的代码,下面这段是核心:
<?php /** * 组装建行H5支付下单参数 * $config 配置项:商户号、私钥路径、公钥路径、网关等 */ function buildCcbPayRequest($config, $order) { // 基础参数,字段顺序大家可以按照文档来,但签名时拼装的顺序要和验签一致 $params = [ 'MERCHANTID' => $config['merchant_id'], // 建行商户号 'ORDERID' => $order['order_no'], // 商户订单号 'PAYMENT' => number_format($order['amount'], 2, '.', ''), // 金额,单位元 'CURCODE' => '01', // 人民币 'TXCODE' => $config['txcode'], // 支付产品交易码 'REMARK' => mb_substr($order['subject'], 0, 60, 'utf-8'), // 备注 'NOTIFYURL' => $config['notify_url'], // 异步回调 'RETURNURL' => $config['return_url'], // 同步跳转 ]; // 生成待签名字符串 $signStr = ''; foreach ($params as $key => $value) { if ($value === '' || $value === null) { continue; } $signStr .= $key . '=' . $value . '&'; } $signStr = rtrim($signStr, '&'); // 用商户私钥签名 $params['SIGNINFO'] = ccbSign($signStr, $config['merchant_private_key']); return $params; }注意几个细节。第一个是PAYMENT金额字段,建行这里用的是元,保留两位小数。很多做过微信支付的兄弟习惯把金额转成分传过去,到了建行这里直接按元传就对了,传成分反而会差100倍,这种低级错误联调时最容易犯。
第二个是参数的顺序,签名串拼接的时候,我建议按固定顺序来,不要用PHP的http_build_query函数去拼。因为建行验签用的拼接逻辑是固定的,你生成的签名串顺序如果跟对方验签的顺序不一致,验签永远失败。稳妥的做法是把参数名排序或者严格按文档列出的字段顺序来拼。
3.2 RSA签名与验签的实现
RSA签名是建行H5支付对接中最容易出问题的环节。我在搭好框架后,光签名验签就折腾了两天,最后发现问题出在公钥格式上。
签名代码,用PHP的openssl扩展实现:
<?php /** * 建行RSA签名 * @param string $data 待签名原始串 * @param string $privateKey 商户私钥(PKCS8 PEM格式) * @return string 签名结果,Base64编码 */ function ccbSign($data, $privateKey) { $key = openssl_pkey_get_private($privateKey); if (!$key) { throw new \Exception('商户私钥格式错误'); } // 摘要算法:具体用SHA256还是SHA1,看接口文档要求 $result = openssl_sign($data, $signature, $key, OPENSSL_ALGO_SHA256); if (!$result) { throw new \Exception('签名失败'); } openssl_free_key($key); return base64_encode($signature); }验签代码:
<?php /** * 建行回调验签 * @param array $params 回调参数数组 * @param string $ccbPublicKey 建行公钥PEM * @return bool */ function ccbVerifySign($params, $ccbPublicKey) { // 提取签名 $signature = $params['SIGNINFO'] ?? ''; if ($signature === '') { return false; } // 去掉签名字段后,按相同规则拼接待验签串 unset($params['SIGNINFO']); $signStr = ''; foreach ($params as $key => $value) { if ($value === '' || $value === null) { continue; } $signStr .= $key . '=' . $value . '&'; } $signStr = rtrim($signStr, '&'); $key = openssl_pkey_get_public($ccbPublicKey); if (!$key) { throw new \Exception('建行公钥格式错误'); } $result = openssl_verify($signStr, base64_decode($signature), $key, OPENSSL_ALGO_SHA256); openssl_free_key($key); return $result === 1; }这里必须重点提醒:建行部分接口文档里的签名串拼接规则不是简单的key=value&拼接。有的文档会要求用|分隔符,有的要求把所有参数值直接拼在一起。我遇到过一种情况是建行老接口用|拼接,新接口改成了key=value&格式,如果你拿到了一个老接口文档,却用了新格式去拼,验签就永远对不上。
所以在你开始写代码之前,把接口文档里关于签名串构造的那一节翻出来,一个字一个字读清楚。文档里通常有个例子,把每一个参与签名的字段和拼接符号都列出来了,照着例子手动拼一遍,验签通过的概率会高很多。
3.3 发起支付请求的完整流程
参数组装好后,怎么把用户带到建行收银台?我采用了最常见的自动提交表单方式:
<?php /** * 输出自动提交表单 * @param array $params 支付请求参数 */ function renderAutoSubmitForm($params) { $html = '<form id="ccbPayForm" action="' . $config['gateway'] . '" method="POST">'; foreach ($params as $key => $value) { $html .= '<input type="hidden" name="' . $key . '" value="' . htmlspecialchars($value) . '" />'; } $html .= '</form>'; $html .= '<script>document.getElementById("ccbPayForm").submit();</script>'; echo $html; }用户打开这个页面后,表单自动POST到建行网关,进入建行收银台。为什么用自动提交而不是直接302重定向?因为建行的H5支付接口约定就是接收FORM表单POST。如果你尝试用GET方式传参,网关会直接拒绝,返回"非法请求"。
这里还要处理好一个体验问题:表单自动提交前,页面不要有太多内容和操作,最好只有一个"正在跳转收银台..."的过渡提示,不然用户看到表单跳出来还以为出错了。
还有一种情况是建行返回的不是让你自动提交,而是返回一个JSON格式的支付链接,需要你自己重定向过去,这种取决于你申请的产品类型和网关版本。我在对接过程中实测是返回HTML收银台地址的方式。如果你拿到的文档写的是返回payUrl,那就用header('Location: '.$payUrl)跳转。
3.4 同步跳转和异步回调的差异处理
用户在建行收银台完成支付后,建行会同时做两件事:让用户的浏览器跳转到RETURNURL,并向NOTIFYURL发一条POST请求。
同步跳转的作用只是给用户一个"支付完成"的页面提示,它不能作为更新订单状态的依据。因为用户可能支付完就立刻关掉浏览器,跳转没发生;也可能恶意用户在没付款的情况下直接访问你的RETURNURL,伪造支付成功页面。所以同步跳转页拿到参数后,可以展示给用户看,但不要改订单状态。
真正要处理的异步回调,代码如下:
<?php /** * 异步回调处理入口 */ public function notify() { // 接收建行POST数据 $params = $_POST; // 1. 验签 if (!ccbVerifySign($params, $config['ccb_public_key'])) { // 验签失败,记录日志,返回错误 file_put_contents('/tmp/ccb_notify_fail.log', json_encode($params), FILE_APPEND); echo 'verify fail'; return; } // 2. 验签通过,获取订单号、金额、支付状态 $orderNo = $params['ORDERID']; $amount = $params['PAYMENT']; $status = $params['PAYMENTSTATUS']; // 具体字段名以文档为准 // 3. 查本地订单,校验订单是否存在、金额是否一致 $order = OrderModel::where('order_no', $orderNo)->find(); if (!$order) { echo 'order not exists'; return; } // 金额校验:回调金额和订单金额必须一致 if (abs(floatval($amount) - floatval($order['amount'])) > 0.01) { echo 'amount mismatch'; return; } // 4. 订单状态幂等判断:已支付的订单不再重复处理 if ($order['status'] === 'paid') { echo 'Success'; return; } // 5. 事务里更新订单状态 Db::transaction(function () use ($orderNo) { OrderModel::where('order_no', $orderNo)->update(['status' => 'paid', 'pay_time' => date('Y-m-d H:i:s')]); // 这里可以加库存扣减、积分发放等业务逻辑 }); // 6. 返回成功标识 echo 'Success'; }关于回调应答,建行的文档要求返回字符串Success或者success,大小写具体看文档。如果你返回了其他内容,建行会认为回调失败,然后进入重试机制。回调重试不是一次两次就放弃的,我见过建行在个别情况下几个小时后还在重试回调。所以回调接口的幂等判断一定得做扎实,否则同一笔订单会被重复处理多次,发货、扣库存这种操作会出大问题。
我处理幂等的方法是双保险:一是订单状态机判断,已支付订单直接返回成功不处理业务;二是在数据库层面把order_no加唯一索引,更新操作带上状态条件,比如UPDATE orders SET status='paid' WHERE order_no=? AND status='pending',用受影响行数判断这次是否是第一次处理。
4. 订单查询与资金对账
4.1 主动查单接口的必要性
前面提到回调有可能丢失,所以商户系统在订单长时间处于"待支付"状态时,主动向建行发起订单查询,是保证资金安全和订单状态一致性的关键。
我的处理策略是:用户支付完成跳转回同步页时,后端先查一次订单;后台跑一个定时任务,每5分钟扫描一次超过10分钟未支付且未关闭的订单,调用建行查询接口与建行系统核对。这样一来,即使回调因为网络波动丢了,最多5分钟后也能通过主动查询把订单状态拉回来。
查单接口的请求参数和下单类似,核心是商户号和订单号:
<?php /** * 查单接口 */ public function queryOrder($orderNo) { $params = [ 'MERCHANTID' => $config['merchant_id'], 'ORDERID' => $orderNo, 'TXCODE' => $config['query_txcode'], ]; $signStr = ''; foreach ($params as $key => $value) { $signStr .= $key . '=' . $value . '&'; } $signStr = rtrim($signStr, '&'); $params['SIGNINFO'] = ccbSign($signStr, $config['merchant_private_key']); // 发起POST请求 $response = curlPost($config['query_gateway'], $params); // 建行返回的响应通常也是一个签名的参数串 parse_str($response, $result); // 验签 if (!ccbVerifySign($result, $config['ccb_public_key'])) { throw new \Exception('查单响应验签失败'); } return $result; }4.2 对账异常的处理经验
查单和回调之间的状态不一致,我们项目里遇到过几类情况,我列在下面做个参考:
| 场景 | 原因 | 处理方案 |
|---|---|---|
| 用户已支付,回调没收到 | 回调通知网络异常/商户系统异常 | 定时任务主动查单,把订单置为已支付 |
| 用户已支付,回调显示失败重试 | 回调处理逻辑抛异常/返回了错误标识 | 排查回调日志,修复后等建行重试 |
| 查单显示支付成功,但金额不一致 | 商户订单金额被篡改或金额单位错误 | 告警,人工介入核查,不要自动发货 |
| 查单显示支付失败,但用户银行卡扣款 | 支付过程异常,银行冲正或退款处理中 | 联系建行处理,以银行流水为准 |
我的建议是,对账异常的单子不要让系统自动处理,全部标记成"待人工审核"状态,人工去建行商户后台核对流水后再手动处理。因为涉及资金的事,自动处理风险太大,处理错了麻烦得很。
5. 常见问题与排查技巧实录
5.1 签名验签类问题
签名验签失败是建行对接里最常见的问题,几乎没有之一。根据我自己的踩坑经验,按照出现频率排序,原因大概是这样的:
- 私钥公钥格式不匹配,比如用PKCS8私钥签名却用PKCS1公钥验签
- 签名串拼接顺序和文档不一致
- 参与签名的字段包含空值,拼接时没有处理
- Base64编码/解码出错,有的语言签名结果是Hex格式,PHP里默认是Base64
- 摘要算法不一致,文档要求SHA1WithRSA,代码里用了SHA256
排查技巧也很朴素:把商户发出的签名串打印出来,和文档里的示例对一下拼接格式;把验签报错时建行返回的原文打出来,用openssl命令行手动验一遍:
# 用建行公钥验证签名,data.txt为原始串,sign.txt为Base64解码后的签名 openssl dgst -sha256 -verify ccb_public.pem -signature sign.txt data.txt如果命令行能验过,说明算法和密钥没问题,那就是代码拼接顺序的问题。如果命令行都过不了,问题大概率出在密钥格式上。
5.2 回调接收不到的问题
回调收不到,先从这几个角度排查:
- 你的回调地址必须是公网可以访问的,而且不能有IP白名单限制。建行服务器回调你的接口时,源IP是建行的公网IP,如果你的服务器防火墙或者应用层做了IP白名单,就把建行的IP段加进去
- 回调地址不能有URL重定向。建行回调遇到301/302会认为回调失败,你的回调接口里别写跳转逻辑
- 回调地址必须支持POST请求。有的同学写的是GET路由,建行POST过来直接404
- 你的服务器响应超时。建行回调等待响应的时间好像比较短,如果你的回调接口里执行了太多的业务逻辑导致响应慢,容易超时。建议做法是:验签通过后立即返回"Success",业务逻辑放到队列或者异步进程去处理
我在项目上线初期就吃过这个亏,回调接口里同步做了库存扣减和短信通知,结果短信通道超时,整个响应超过了建行等待时间,建行就一直重试,造成了订单重复处理的假象。后来改成异步处理,问题才彻底解决。
5.3 本地开发调试技巧
银行系的接口没有沙箱测试环境吗?有,但建行的沙箱环境和正式环境割裂感很强。我自己的做法是,开发阶段申请了一套正式商户号,用1分钱、1块钱这种小额真实支付来测试。风险可控,流程真实,问题暴露得最彻底。
如果你想完全在本地调试回调逻辑,可以用内网穿透工具把本机服务暴露到公网,生成一个临时域名,把这个临时域名作为回调地址配到支付请求里。这样每次支付回调都能打到本地,调试效率高很多。不过要注意,临时域名如果频繁变更,回调地址也要同步改,别改漏了。
5.4 上线前必须检查的清单
最后,我把上线前检查清单放在这里,这个清单是我经历过线上故障后总结出来的,每次发版我都会过一遍:
- 回调接口是不是返回了指定成功标识,大小写对不对
- 回调处理是不是幂等的,同一笔订单重复回调不会重复发货
- 订单金额有没有做二次校验,回调金额和订单金额不一致时会不会拒绝
- 查单定时任务有没有跑起来,任务频率是不是合理
- 日志有没有记录完整,下单、回调、查单、异常各环节都要有日志,出问题才能快速定位
- 商户私钥权限有没有收好,密钥文件不要让web用户可读,建议放到项目目录之外或者配置到环境变量里
对接建行H5支付,说难也不难,核心就是三件事:签名验签搞对、回调处理幂等、查单对账兜底。把这三件事做扎实,线上基本不会有太大问题。我这边项目上线半年多了,交易量不算大,几千笔还是有的,真正出问题的就两笔,还都是银行侧资金状态异常,人工对账后处理完毕。最后再多说一句,银行接口的文档年年可能更新,字段和网关地址以你申请到的版本为准,不要拿网上别人两年前的代码直接粘贴,思路可以借鉴,细节一定要自己对照文档核对。