背景
最近在对接建行的支付,我们做的是被扫支付,就是B扫C,一开始对方发了一个压缩包给我,看起来挺齐全的,文档、demo啥的都有,以为很简单,跟微信支付宝类似,调一下接口,验证一下就OK了。然而,事实证明我还是太年轻了。而且网络上你能够搜到的基本上都用不了,所以记一下博客,或许可以帮助其他人。
先说一下建行支付比较特殊的地方吧
1、官方提供的demo里面,只有Java和.Net是有真正的demo,PHP和其他语言没有,只提供一个dll文件,几乎没什么用
2、计算加密串的时候,待加密的数据要转为十六进制
3、建行通知返回的SIGN是十六进制的,要转为十进制
4、建行提供的公钥是DER格式的,十六进制,而 MD5withRSA 进行加密验证的时候,要转成PEM格式
5、建行被扫支付文档虽然说要用POST,但是实际上只能用GET
6、退款也是很恶心的一个东西,建行的退款走接口的话只能用外联平台退款,支付接口里面退款的描述就几句话
由于笔者是用PHP进行开发的,既然官方没有提供PHP版的demo,只能根据Java版的翻译成PHP版的。至于退款,只能开一个外联平台服务进行处理了
支付
可参考笔者的 Github 项目,里面包含了完整的PHP加密验签方法,也包含了Java版的处理
下面简单介绍下
签名计算流程
- 将所有的请求参数去掉空值,并按key升序排序
- 将第一步得到的数据,按key=value的形式进行拼接,用&隔开
- 将拼接后的字符串再拼接上"20120315201809041004"
- 将最后得到的字符串进行MD5加密,就是SIGN的值
加密串计算流程
- 把上面签名后的结果以键值对的形式放入请求参数中(所有的请求参数,含空值),键名是SIGN
- 将第一步得到的请求参数,按key=value的形式进行拼接,用&隔开,得到待加密的字符串
- 截取公钥的后30位,再截取这30位的前8位,得到一个8位的字符串,这个是参与加密串计算的公钥
- 先将第二步得到的待加密的字符串从"utf-8"编码转为"utf-16",并与第三步得到的8位的公钥用"DES-ECB"进行加密
- 把第四步得到的加密结果中的"+"替换为","
- 再对第五步的结果进行UrlEncode编码,得到的结果就是ccbParam
验签流程
- 建行接口所有返回的参数,只取接口文档中的"签名源文格式"中相关的数据,作为验签源数据
- 将返回的签名字段SIGN(十六进制),转为十进制
- 建行的公钥是DER格式的,且是十六进制,需要转为PEM格式。将完整的公钥转为十进制,同时进行base64编码,拼接上"-----BEGIN PUBLIC KEY-----"和"-----END PUBLIC KEY-----"做成pem
- 提取第三步得到的PEM证书的公钥
- 将第一步得到的验签源数据,按key=value的形式进行拼接,用&隔开,作为新的源数据
- 使用MD5withRSA方法,将十进制的SIGN、源数据以及提取的公钥进行验证
代码:
ccbPay.php
<?php require_once './ccbUtils.php'; /** * 被扫支付:建行互联网银企被扫支付(聚合) * Class ccbPay */ class ccbPay { // 商户号 const MERCHANTID = '105910100190000'; // 柜台号 const POSID = '000000000'; // 分行号 const BRANCHID = '610000000'; // 建行支付公钥 const PUBKEY = '30819d300d06092a864886f70d010101050003818b0030818702818100a32fb2d51dda418f65ca456431bd2f4173e41a82bb75c2338a6f649f8e9216204838d42e2a028c79cee19144a72b5b46fe6a498367bf4143f959e4f73c9c4f499f68831f8663d6b946ae9fa31c74c9332bebf3cba1a98481533a37ffad944823bd46c305ec560648f1b6bcc64d54d32e213926b26cd10d342f2c61ff5ac2d78b020111'; // 请求接口域名 const HOST = 'https://ibsbjstar.ccb.com.cn/CCBIS/B2CMainPlat_00_BEPAY'; /** * 建行支付,被扫 */ public function pay() { $data = [ 'MERCHANTID' => self::MERCHANTID, // 商户号 'POSID' => self::POSID, // 柜台号 'BRANCHID' => self::BRANCHID, // 分行号 'GROUPMCH' => '', // 集团商户信息 'TXCODE' => 'PAY100', // 交易码 'MERFLAG' => '', // 商户类型 'TERMNO1' => '', // 终端编号 1 'TERMNO2' => '', // 终端编号 2 'ORDERID' => '', // 订单号 'QRCODE' => '', // 码信息(一维码、二维码) 'AMOUNT' => '0.01', // 订单金额,单位:元 'PROINFO' => '', // 商品名称 'REMARK1' => '', // 备注 1 'REMARK2' => '', // 备注 2 'FZINFO1' => '', // 分账信息一 'FZINFO2' => '', // 分账信息二 'SUB_APPID' => '', // 子商户公众账号 ID 'RETURN_FIELD' => '', // 返回信息位图 'USERPARAM' => '', // 实名支付 'detail' => '', // 商品详情 'goods_tag' => '', // 订单优惠标记 ]; $ccbUtils = new ccbUtils(); // 计算签名 $sign = $ccbUtils->calSign($ccbUtils->sortParams($data)); $data['SIGN'] = $sign; // 计算加密串 $params = http_build_query($data); $pubKey = substr(self::PUBKEY, -30); $pubKey = substr($pubKey, 0, 8); $data['ccbParam'] = $ccbUtils->calCcbParam($params, $pubKey); // 获取要请求的参数 $requestData = $ccbUtils->getRequestData($data); $url = self::HOST . '?' . http_build_query($requestData); var_dump($url); } /** * 支付查询 */ public function query() { $data = [ 'MERCHANTID' => self::MERCHANTID, // 商户号 'POSID' => self::POSID, // 柜台号 'BRANCHID' => self::BRANCHID, // 分行号 'GROUPMCH' => '', // 集团商户信息 'TXCODE' => 'PAY101', // 交易码 'MERFLAG' => '', // 商户类型 'TERMNO1' => '', // 终端编号 1 'TERMNO2' => '', // 终端编号 2 'ORDERID' => '', // 订单号 'QRYTIME' => '', // 查询次数 从1开始 'QRCODE' => '', // 码信息(一维码、二维码) 'QRCODETYPE' => '', // 二维码类型 如未上送 QRCODE 则此参数为必输 'REMARK1' => '', // 备注 1 'REMARK2' => '', // 备注 2 'SUB_APPID' => '', // 子商户公众账号 ID 'RETURN_FIELD' => '', // 返回信息位图 ]; // 与支付的区别TXCODE不一样,需要传QRYTIME,QRCODE和QRCODETYPE两个需传一个 // 后续计算签名和加密串跟支付类似 } public function refund() { // 退款只能走外联平台 } /** * 建行返回参数sign验签 */ public function checkCcbSign() { // 建行返回的数据 $returnData = [ 'RESULT' => 'Y', 'ORDERID' => '151677281312212', 'AMOUNT' => '0.01', 'WAITTIME' => 'null', 'TRACEID' => '1010115031516772964428432', 'SIGN' => '80c3298a47b26cb9d8d708e1465c6b521edcce32b0deecab91257a3f41fc6cf39fa43afa54dc8489a04615eee9dcca1f4b52ce677f70109f29745ff34033018353b78e982cc860623b6c3df0d9c1a62ca010a019fff8544d4d8e154a010d7fc16cb590ccd87f34d8bea6added68cf1f9943fdb1d83616507a4588b68774b9fe1' ]; $ccbUtils = new ccbUtils(); $result = $ccbUtils->checkSign($ccbUtils->getCalSignData($returnData, ccbUtils::SIGN_CCB_PAY), self::PUBKEY); var_dump($result); } }
ccbUtils.php
<?php class ccbUtils { // 加密MD5 key const MD5KEY = '20120315201809041004'; // 验证签名用到的类型,1-支付接口,2-查询接口 const SIGN_CCB_PAY = 1; const SIGN_CCB_QUERY = 2; /** * 按key升序排序,同时去掉空值 * @param $params array * @return mixed */ public function sortParams($params) { ksort($params); foreach ($params as $key => $value) { if (empty($value) && $value == '') { unset($params[$key]); } } return $params; } /** * 计算签名 * @param $params array 不含空值 * @return string */ public function calSign($params) { return md5(http_build_query($params) . self::MD5KEY); } /** * 计算ccbparam * @param $params string * @param $key string * @return string */ public function calCcbParam($params, $key) { $res = openssl_encrypt (iconv("utf-8", "utf-16", $params), 'DES-ECB', $key); $res = str_replace('+', ',', $res); $res = urlencode($res); return $res; } /** * 真正请求建行接口要传的参数 * @param $data array * @return array */ public function getRequestData($data) { return [ 'MERCHANTID' => $data['MERCHANTID'], 'POSID' => $data['POSID'], 'BRANCHID' => $data['BRANCHID'], 'ccbParam' => $data['ccbParam'], ]; } /** * 获取要验证签名的参数 * @param $data array * @param $type int * @return array */ public function getCalSignData($data, $type) { switch ($type) { case self::SIGN_CCB_PAY: $res = [ 'RESULT' => $data['RESULT'], 'ORDERID' => $data['ORDERID'], 'AMOUNT' => $data['AMOUNT'], 'WAITTIME' => $data['WAITTIME'], 'TRACEID' => $data['TRACEID'], 'SIGN' => $data['SIGN'] ]; break; case self::SIGN_CCB_QUERY: $res = [ 'RESULT' => $data['RESULT'], 'ORDERID' => $data['ORDERID'], 'AMOUNT' => $data['AMOUNT'], 'WAITTIME' => $data['WAITTIME'], 'SIGN' => $data['SIGN'] ]; break; default: $res = []; break; } return $res; } /** * 验证签名 * @param $data array * @param $key string * @return bool */ public function checkSign($data, $key) { if (empty($data)) { return false; } $sign = $data['SIGN']; unset($data['SIGN']); $data = http_build_query($data); $pubkey = "-----BEGIN PUBLIC KEY----- " . wordwrap(base64_encode(self::Hex2String($key)), 64, " ", true) . " -----END PUBLIC KEY-----"; $pkeyId = openssl_pkey_get_public($pubkey); $verify = openssl_verify($data, self::Hex2String($sign), $pkeyId, OPENSSL_ALGO_MD5); openssl_free_key($pkeyId); return (bool) $verify; } /** * 十六进制转字符串 * @param $hex string * @return string */ private function Hex2String($hex) { $string = ''; for ($i = 0; $i < strlen($hex) - 1; $i += 2) { $string .= chr(hexdec($hex[$i] . $hex[$i + 1])); } return $string; } /** * 字符串转十六进制 * @param $str string * @return string */ private function String2Hex($str){ $hex=''; for ($i=0; $i < strlen($str); $i++){ $hex .= dechex(ord($str[$i])); } return $hex; } }
退款
建行退款只提供两种方式
1、登录商户服务平台,手工处理退款
2、走外联平台服务进行退款
官方给的文档,教你搭建外联平台都是基于Windows的,Linux的几乎没有,而且搭建流程非常复杂,而且你还得找一台服务器专门用来退款。Excuse me?
笔者提供一个 Github 项目,只用使用里面的 jar 包,开启一个服务就可以处理退款请求了
启动服务,绑定的是8080端口
# java -jar ccb-cloud-sdk-1.0-SNAPSHOT.jar
请求实例:
接口:http://127.0.0.1:8080/ccb/pay/refund 请求参数: { "merchantId": "商户号", "custId": "操作员账号", // 登录建行商户平台-服务管理-操作员管理,列表里面的客户号 "transPwd": "操作员交易密码", // 创建操作员时候填的 "certPassword": "证书密码", // 导出证书的时候填的密码 "txCode": "5W1004", // 参考"外联平台商户开发接口_V4.0.chm",退款是这个"5W1004" "language": "CN", "url": "https://merchant.ccb.com", "certFilePath": "/config/MC123456789.pfx", // 使用绝对路径 "configFilePath": "/config/config.xml", // 使用绝对路径 "refundNo": "序列号", // 16位以内纯数字 "refundAmt": "退款金额", // 单位:元 "payRecordNo": "交易单号" // 交易的时候你传给建行的单号 } 返回参数: { "return_CODE": "000000", // 参考"外联平台商户开发接口_V4.0.chm" "return_MSG": "退款成功", // 参考"外联平台商户开发接口_V4.0.chm" "order_NUM": "交易单号", // 交易的时候你传给建行的单号 "tx_INFO": "" // 建行接口返回原文 }
退款麻烦麻烦在需要在建行商户平台配置一个操作员账号,此外还需要导出证书和配置,其他的基本上没了