一、背景
公司的支付平台已对接了微信官方和杭州银行两个支付渠道,介于费率的问题,偏向于使用后者。前段时间,杭州银行该支付通道因为不知名的原因被封,导致只能使用微信官方。
所以我们迫切需要再介入一家银行,防备下次什么时候被封禁。
本文将要描述和农行对接的详细步骤以及踩过的那些坑,因为踩过坑,希望后来者不要再浪费那么的时间,后期我也会尽量将对接的sdk上传到github开源。
二、银行支付
站在用户的角度,扫码或者JSAPI支付,都是偏向于微信和支付宝,极少数去下载各个银行APP,然后使用银行的APP付款。正因为微信和支付宝的用户群体众多,而且它可以绑定各个银行的银行卡或信用卡进行支付,使得银行方也不得不与之合作。
所谓合作,就是我们的请求方本来是微信或支付宝,现在是银行方,类似一个代理者的角色。
用户的支付体验不变,但是钱是付款到了银行方,由银行方和微信或支付宝对接。
说了这么多,下面简单画一个图,便于小白理解:
用户是使用微信付款的,钱在农业银行的卡里,业务方是和农业银行进行对账,不用去管微信和支付宝。
三、主角登场--农业银行
本文重在梳理从加载证书--》拼接请求报文(明文+签名)--》发送http--》解析响应报文(可能是密文+验签)的流程来表述。在第三篇文章,我将对他们的对接进行一一吐槽。
3.1 加载证书
在应用的容器初始化后,遍历配置中的账户列表,入参为商户对象。开始初始化根证书文件abc.truststore,取得TrustManager对象,并赋值给SSLContext。
有了SSLContext,接下里就是将它赋值给HttpClient的连接池管理器PoolingHttpClientConnectionManager, 并最终将账户列表缓存在Map里,减少读取文件的效率,每次发起交易都去读取证书文件锁引起的耗时是非必要的。
3.2 发送报文
这一步就是一个简单的https请求了,Post方式,报文内容格式是JSON。示例报文在后面的文章里有。头信息和请求参数都不需要传,传入的StringEntity的“Content-Type: text/xml”。
一个HttpUtil.java类就搞定了。
拼接好了明文后,必须对它进行签名,一并发送给农行。
签名就必须要用到商户的私钥证书pfx的PrivateKey了。计算签名主要是jdk自带的类java.security.Signature.java
3.3 处理响应
收到响应报文,必须验证签名。
先把签名字段进行base64解码,和使用支付平台证书对响应明文所计算出来的签名,两者比较是否相等。
如果签名一致,就可以使用json对响应报文进行取值了。
四、技术点罗列
公司内部的接口对接,比较简单,除规定http/https的url地址, 还需要说明使用的方法get/post/put/delete,还有一个比较重要的字段就是Content-Type了,指定处理请求的提交内容类型(Content-Type),例如application/json, text/html。
农行使用的正是xml格式。但是农行的报文内容,又是json格式,除了支付通知回调报文外。
4.1、签名
public static String sign(final String inputMessage, final String encoding, final AbcMerchantInfo merchantInfo) {
String signedMessage = "";
try {
Signature tSignature = Signature.getInstance(AbcBankConfig.SIGNATURE_ALGORITHM_VALUE);
//商户的私钥文件.pfx中读取的PrivateKey
tSignature.initSign(merchantInfo.getPrivateKey());
tSignature.update(inputMessage.getBytes(encoding));
final byte[] tSigned = tSignature.sign();
final Base64 tBase64 = new Base64();
final String tSignedBase64 = tBase64.encode(tSigned);
signedMessage = "{\"Message\":" + inputMessage + ","
+ "\"Signature-Algorithm\":" + "\"" + AbcBankConfig.SIGNATURE_ALGORITHM_VALUE + "\"" + ","
+ "\"Signature\":" + "\"" + tSignedBase64 + "\"}";
} catch (Exception e) {
log.error("农业银行生成签名出现异常, mchId = {}", merchantInfo.getMerId(), e);
throw new IllegalArgumentException("农业银行生成签名出现异常", e);
}
return signedMessage;
}
/**
* 填写pfx的文件路径和读取密码,将私钥信息赋值给AbcMerchantInfo对象
**/
public static void bindMerchantCertificateByFile(AbcMerchantInfo merchantInfo, String merPfxFile, String merPfxPassword) {
try (FileInputStream tIn = new FileInputStream(merPfxFile)) {
KeyStore tKeyStore = KeyStore.getInstance("PKCS12", new Provider().getName());
tKeyStore.load(tIn, merPfxPassword.toCharArray());
// 读取证书内容
String tAliases = "";
final Enumeration e2 = tKeyStore.aliases();
if (e2.hasMoreElements()) {
tAliases = (String) e2.nextElement();
}
Certificate tCert = tKeyStore.getCertificate(tAliases);
final Base64 tBase64 = new Base64();
String merCertificate = tBase64.encode(tCert.getEncoded());
merchantInfo.setMerCertificate(merCertificate);
// 校验证书
final X509Certificate tX509Cert = (X509Certificate) tCert;
tX509Cert.checkValidity();
// 读取证书私钥
PrivateKey privateKey = (PrivateKey) tKeyStore.getKey(tAliases, merPfxPassword.toCharArray());
merchantInfo.setPrivateKey(privateKey);
} catch (Exception e) {
log.error("读取农行的商户证书文件出现异常, merPfxFile={}", merPfxFile, e);
throw new IllegalStateException("读取农行的商户证书文件出现异常", e);
}
}
4.2、验签
private static boolean verify(final String tTrxResponse, final String tAlgorithm, final String tSignBase64,
final String encoding, final AbcMerchantInfo merchantInfo) {
final Base64 tBase64 = new Base64();
final byte[] tSign = tBase64.decode(tSignBase64);
try {
final Signature tSignature = Signature.getInstance(tAlgorithm);
// TrustPay.cer文件,它是一个java.security.cert.Certificate对象。
tSignature.initVerify(merchantInfo.getTrustPayCertFile());
tSignature.update(tTrxResponse.getBytes(encoding));
return tSignature.verify(tSign);
} catch (Exception e) {
log.error("农业银行校验签名出现异常,mchId = {}", merchantInfo.getMerId(), e);
throw new IllegalArgumentException("农业银行校验签名出现异常", e);
}
}
// 在应用初始化的时候,读取TrustPay.cer文件,然后存放在应用的内存里
merchantInfo.setTrustPayCertFile(getCertificate(certPath + param.getTrustPayCertFileName()));
public static Certificate getCertificate(final String certFile) {
Certificate tCertificate = null;
try (FileInputStream tIn = new FileInputStream(certFile)) {
final byte[] tCertBytes = new byte[4096];
int tCertBytesLen = tIn.read(tCertBytes);
final byte[] tFinalCertBytes = new byte[tCertBytesLen];
for (int i = 0; i < tCertBytesLen; ++i) {
tFinalCertBytes[i] = tCertBytes[i];
}
Security.addProvider(new Provider());
final CertificateFactory tCertificateFactory = CertificateFactory.getInstance("X.509");
final ByteArrayInputStream bais = new ByteArrayInputStream(tFinalCertBytes);
if (bais.available() > 0) {
tCertificate = tCertificateFactory.generateCertificate(bais);
}
} catch (Exception e) {
log.error("加载农行的cert文件出现异常,certFile={}", certFile, e);
throw new IllegalArgumentException("加载农行的cert文件出现异常", e);
}
return tCertificate;
}
4.3、https请求
官方示例采用的是httpclient3.x,我这里升级到了4.x,因为spring cloud feign基本要使用httpclient的话,也将是4.x了。
其实有了上面的SSLContext, 想要发起https请求的代码写起来就容易了。网上搜索一大把示例,就不赘述了。
前文也说了,农行一会是xml,一会是json,让你晕头转向不说,关键是在计算签名和验证签名的时候,还基础不对。而它给的示例,就是采用字符串的拼接,并没有去引用json库或者xml库。
最后我保留了它提供的4个类:Base64.java;Base64Code.java; JSON.java; XMLDocument.java。
根据上面的步骤,我写了四个类:
- 类AbcBankSignUtil.java(签名和验签);
- 类AbcBankReadMerFileUtils.java(读取证书文件,pfx/cer/truststore)
- 类AbcBankHttpService.java(发送https请求)
- 类AbcBankCertCache.java(缓存,减少重复的读取证书文件)