一、背景
本文不是要讲述支付服务的对账模块具体怎么做,仅是介绍如何对接浦发银行的对账接口。
也就是说,本文限读取到对账文件的内容,不会进一步去讲述如何与支付平台进行对账。
如果要获取商户的对账单,需要遵循以下步骤,涉及到浦发银行的两个接口。
- 对公收单API对账单下载
- 公共文件下载
二、对接流程
三、浦发银行开放平台
上文说到,要想获取对账文件内容,需要对接两个接口。所以,你需要在开放平台进行申请。
否则会报500错误:{ "httpCode":"500", "httpMessage":"Internal Server Error", "moreInformation":"Not registered to plan" }
待审批通过后,就可以开始联调接口了。
四、接口说明
1、对公收单API对账单下载
这个接口的调用方式和之前的接口一样。请求入参和响应报文都非常易懂,最终为了得到对账单文件fileId,作为下一个接口的入参。
-
接口URI:/api/corporateAccounts/payments/statements
-
请求方式:GET
-
请求入参:
-
响应报文:
-
示例报文(成功报文)
bash
# 请求报文:
{
"mrchId": "310319982990001",
"clrgDate": "20240418"
}
# 响应报文:
{
"statusCode": "0000",
"transNo": "04972404201170910292596024",
"status": "UPLOADED",
"flDwnldNtrlnkg": "SCMCHT_DTL_310319982990001_20240418.txt",
"errCode": "",
"errInfo": ""
}
- 对账文件还未上传时的报文示例:
bash
# 请求报文:
{
"mrchId": "310319982990001",
"clrgDate": "20240418"
}
# 响应报文:
{
"statusCode": "0000",
"transNo": "04972404191111116409199107",
"status": "UPLOADING",
"flDwnldNtrlnkg": "",
"errCode": "",
"errInfo": ""
}
2、公共文件下载
-
接口URI:/apiFile/download
-
请求方式:GET
-
请求入参:
注意:fileId参数是跟在url中,比如:http://etest4.spdb.com.cn/spdb/uat/apiFile/download?fileId=SCMCHT_DTL_310319982990001_20240418.txt
- 响应报文:
返回内容是通过二进制流的方式,
判断http header的statusCode是否等于0000,如果交易失败,那么在http response body里将返回以下字段:
反之,当交易成功的时候,它则不会返回httpCode、httpMessage和moreInformation等字段,取而代之,返回的是文件流,见下:
由下面的对账单内容可知,有用的信息只有交易时间/浦发银行支付/退款流水号以及交易金额等字段。 注意:这里没有返回商户支付订单号,对于要两边对账的情况会有麻烦。。(限于篇幅,后期有空再单独讲述如何解析对账文本吧,对账文本要取得交易金额都够喝一壶的:因为所有字段之间不是使用"="符号隔开,它这里必须使用跳表符"\t"来隔开,最后去掉前后空格字符才是交易金额)
- 当日既无支付或退款流水
bash
="商户扫码支付交易对账明细表"
="清算日期:" ="20240420"
="商户编号:" ="310319982990001" ="商户名称:" ="xxx公司"
="商户清算周期:" ="T+1" ="开户行:" ="xxx银行" ="户名:" ="xxx"
="清算账号类型:" ="他行对私" ="清算账号:" ="62********xxx7"
="按终端号汇总:"
="终端号" ="交易时间" ="交易类型" ="交易渠道" ="流水号" ="订单号" ="交易本金" ="应收商户手续费" ="清算金额" ="交易参考号" ="发卡行名称" ="支付账号"
="按交易类型汇总:"
="交易类型" ="交易时间" ="终端号" ="交易渠道" ="流水号" ="订单号" ="交易本金" ="应收商户手续费" ="清算金额" ="交易参考号" ="发卡行名称" ="支付账号"
="按交易渠道汇总:"
="交易渠道" ="交易时间" ="终端号" ="交易类型" ="流水号" ="订单号" ="交易本金" ="应收商户手续费" ="清算金额" ="交易参考号" ="发卡行名称" ="支付账号"
="总计" ="笔数:" 0 0.00 0.00 0.00
- 当日有支付或退款流水
bash
="商户扫码支付交易对账明细表"
="清算日期:" ="20240419"
="商户编号:" ="310319982990001" ="商户名称:" ="xxx公司"
="商户清算周期:" ="T+1" ="开户行:" ="xx银行" ="户名:" ="xxxx"
="清算账号类型:" ="他行对私" ="清算账号:" ="62********xxx7"
="按终端号汇总:"
="终端号" ="交易时间" ="交易类型" ="交易渠道" ="流水号" ="订单号" ="交易本金" ="应收商户手续费" ="清算金额" ="交易参考号" ="发卡行名称" ="支付账号"
="98A00162" ="0419095033" ="扫码支付" ="微信 " ="072760" ="1901041909503311585281072760" 0.01 0.01 0.00 =" " =" " =""
="98A00162" ="0419095543" ="扫码退货" ="微信 " ="072786" ="5901041909554311128351072786" -0.01 0.00 -0.01 =" " =" " =""
="98A00162" ="0419102811" ="扫码支付" ="微信 " ="072951" ="1901041910281111156541072951" 0.01 0.01 0.00 =" " =" " =""
="98A00162" ="0419103146" ="扫码退货" ="微信 " ="073054" ="5901041910314611136322073054" -0.01 0.00 -0.01 =" " =" " =""
="小计" ="笔数:" 4 0.00 0.02 -0.02
="按交易类型汇总:"
="交易类型" ="交易时间" ="终端号" ="交易渠道" ="流水号" ="订单号" ="交易本金" ="应收商户手续费" ="清算金额" ="交易参考号" ="发卡行名称" ="支付账号"
="扫码支付" ="0419095033" ="98A00162" ="微信 " ="072760" ="1901041909503311585281072760" 0.01 0.01 0.00 =" " =" " =""
="扫码支付" ="0419102811" ="98A00162" ="微信 " ="072951" ="1901041910281111156541072951" 0.01 0.01 0.00 =" " =" " =""
="小计" ="笔数:" 2 0.02 0.02 0.00
="扫码退货" ="0419095543" ="98A00162" ="微信 " ="072786" ="5901041909554311128351072786" -0.01 0.00 -0.01 =" " =" " =""
="扫码退货" ="0419103146" ="98A00162" ="微信 " ="073054" ="5901041910314611136322073054" -0.01 0.00 -0.01 =" " =" " =""
="小计" ="笔数:" 2 -0.02 0.00 -0.02
="按交易渠道汇总:"
="交易渠道" ="交易时间" ="终端号" ="交易类型" ="流水号" ="订单号" ="交易本金" ="应收商户手续费" ="清算金额" ="交易参考号" ="发卡行名称" ="支付账号"
="微信 " ="0419095033" ="98A00162" ="扫码支付" ="072760" ="1901041909503311585281072760" 0.01 0.01 0.00 =" " =" " =""
="微信 " ="0419095543" ="98A00162" ="扫码退货" ="072786" ="5901041909554311128351072786" -0.01 0.00 -0.01 =" " =" " =""
="微信 " ="0419102811" ="98A00162" ="扫码支付" ="072951" ="1901041910281111156541072951" 0.01 0.01 0.00 =" " =" " =""
="微信 " ="0419103146" ="98A00162" ="扫码退货" ="073054" ="5901041910314611136322073054" -0.01 0.00 -0.01 =" " =" " =""
="小计" ="笔数:" 4 0.00 0.02 -0.02
="总计" ="笔数:" 4 0.00 0.02 -0.02
bash
# 报没有下载权限的错误
{
"httpCode": "500",
"httpMessage": "Internal Server Error",
"moreInformation": "No download privileges"
}
- No fileId报错
文件ID参数的传送方式不对,因为我错把fileId放在http reqeust body里。
bash
# 报未传fileId的错误
{
"httpCode": "400",
"httpMessage": "Request Params Error",
"moreInformation": "No fileId"
}
四、公共文件下载接口的代码实现
因为该接口和其他接口的特殊差异,故此特别指出。
1、生成普通签名(详见下文)
bash
# 浦发开放平台,申请的app的secret
String secret = "ZDUkZC00NmZ0LTxxxxxxxxxU2ZWZ2MmZxMC44NTU3Mzk4MDE0NjQ1NTg1MC4w";
String sign = SPDBSMSignature.downloadSign(sm2PrivateKey, "fileId=" + fileId);
2、传递http header字段
bash
String clientId = "bf4b4874-xxxxxxxxx-a318-7631afbd14a7";
HttpResponse response = HttpRequest.get(fileUrl)
# 浦发开放平台申请的APP
.header("X-SPDB-Client-ID", clientId)
# 上一步生成的签名
.header("X-SPDB-SIGNATURE", sign)
.header("X-SPDB-SM", "true")
.header("X-SPDB-LABEL", "0001")
.execute();
3、响应解析
bash
if (response.isOk()) {
if ("0000".equals(response.header("statusCode"))) {
InputStream byteStream = response.bodyStream();
try {
# 注意,这里的编码格式选择GBK,否则内容会出现乱码
String content = IOUtils.toString(byteStream, "GBK");
if (log.isInfoEnabled()) {
log.info("读取对账单文件, fileUrl={},content={}", fileUrl, content);
}
return content;
} catch (IOException e) {
log.error("读取文件内容出现异常, fileUrl={}", fileUrl, e);
}
}
} else {
log.warn("调用浦发银行对账单接口返回报错, fileUrl={}, status={}, body={}",
fileUrl, response.getStatus(), response.body());
}
五、普通验签
bash
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.Security;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import org.apache.commons.codec.digest.DigestUtils;
import org.bouncycastle.crypto.digests.SM3Digest;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.pqc.math.linearalgebra.ByteUtils;
/**
* API公共对象存储 for JAVA
* 要求 jdk版本 1.8 以上
*/
public class SPDBSMSignature {
static {
Security.addProvider(new BouncyCastleProvider());
}
// 算法名称
public static final String ALGORITHM_NAME = "sm4";
// P5填充
public static final String ALGORITHM_NAME_ECB_PADDING = "SM4/ECB/PKCS5Padding";
/**
* 密钥加密
*
* @param algorithm 算法名称
* @param content 密钥
* @param charset 编码格式
* @return
*/
public static String keyDigest(String algorithm, String content, String charset) {
try {
MessageDigest digest = MessageDigest.getInstance(algorithm);
digest.update(content.getBytes(charset));
byte[] digestBytes = digest.digest();
return DatatypeConverter.printHexBinary(digestBytes).toLowerCase();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 请求报文体加密
*
* @param algorithm 算法名称
* @param content 请求报文体
* @param charset 编码格式
* @return
*/
public static String dataDigest(String algorithm, byte[] content, String charset) {
try {
MessageDigest digest = MessageDigest.getInstance(algorithm);
digest.update(content);
byte[] digestBytes = digest.digest();
return DatatypeConverter.printBase64Binary(digestBytes);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* MD5加密
*
* @param hash
* @return
*/
public static String md5Digest(String hash) {
String md5Str = DigestUtils.md5Hex(hash);
return md5Str;
}
/**
*
* 说明:sm3加密处理
*
* @param data
* @return 2019年11月26日
*
*/
public static String sm3(String data) {
String charset = "UTF-8";
String sm3Data = "";
try {
byte[] dataBytes = data.getBytes(charset);
byte[] hashBytes = hash(dataBytes);
sm3Data = ByteUtils.toHexString(hashBytes);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return sm3Data;
}
/**
*
* 返回长度为32位的byte数组 生成对应的hash值
*
* @param dataBytes
* @return 2019年10月28日
*
*/
public static byte[] hash(byte[] dataBytes) {
SM3Digest digest = new SM3Digest();
digest.update(dataBytes, 0, dataBytes.length);
byte[] hash = new byte[digest.getDigestSize()];
digest.doFinal(hash, 0);
return hash;
}
/**
* P5填充加密
*
* @param key 密钥
* @param data 请求报文体
* @return
*/
public static byte[] encrypt(byte[] key, byte[] data) {
try {
Cipher cipher = Cipher.getInstance(ALGORITHM_NAME_ECB_PADDING,
BouncyCastleProvider.PROVIDER_NAME);
SecretKeySpec sm4Key = new SecretKeySpec(key, ALGORITHM_NAME);
cipher.init(Cipher.ENCRYPT_MODE, sm4Key);
return cipher.doFinal(data);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* P5填充解密
*
* @param key 密钥
* @param signature 签名
* @return
*/
public static byte[] decrypt(byte[] key, byte[] signature) {
try {
Cipher cipher = Cipher.getInstance(ALGORITHM_NAME_ECB_PADDING,
BouncyCastleProvider.PROVIDER_NAME);
SecretKeySpec sm4Key = new SecretKeySpec(key, ALGORITHM_NAME);
cipher.init(Cipher.DECRYPT_MODE, sm4Key);
return cipher.doFinal(signature);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 签名
*
* @param key 密钥
* @param data 请求报文体
* @return
*/
public static String sign(String key, byte[] data) {
try {
String charset = "UTF-8";
String shaKey = keyDigest("SHA-256", key, charset);
String sm3Key = sm3(shaKey);
String sm4Key = md5Digest(sm3Key);
String sm4Data = sm3(dataDigest("SHA-1", data, charset));
byte[] keyBytes = ByteUtils.fromHexString(sm4Key);
byte[] dataBytes = sm4Data.getBytes(charset);
byte[] encryptBytes = encrypt(keyBytes, dataBytes);
String hexSignature = ByteUtils.toHexString(encryptBytes).toUpperCase();
byte[] signBytes = hexSignature.getBytes(charset);
return DatatypeConverter.printBase64Binary(signBytes);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 验签
*
* @param sm4Key 密钥
* @param signature 签名
* @param data 请求报文体
* @return
*/
public static boolean validateSign(String key, String signature, byte[] data) {
String charset = "UTF-8";
String shaKey = keyDigest("SHA-256", key, charset);
String sm3Key = sm3(shaKey);
String sm4Key = md5Digest(sm3Key);
byte[] keyBytes = ByteUtils.fromHexString(sm4Key);
String sm4Data = sm3(dataDigest("SHA-1", data, charset));
byte[] signBytes = DatatypeConverter.parseBase64Binary(signature);
String hexSignature = new String(signBytes).toLowerCase();
byte[] cipherBytes = ByteUtils.fromHexString(hexSignature);
byte[] decrypt = decrypt(keyBytes, cipherBytes);
String cipherData = new String(decrypt);
return sm4Data.equals(cipherData);
}
public static String downloadSign(String key, String data) throws UnsupportedEncodingException{
String sign = sign(key, data.getBytes("UTF-8"));
return sign;
}
public static String metadata(String filename, String filesize){
String sha1Sign = dataDigest("SHA-1", filename.getBytes(), "UTF-8");
String metadata = "{\"fileName\":\""+filename+"\",\"fileSize\":\""+filesize+"\",\"fileSha1\":\""+sha1Sign+"\"}";
return metadata;
}
public static String uploadSign(String key, String metadata) throws UnsupportedEncodingException{
String sign = sign(key, metadata.getBytes("UTF-8"));
return sign;
}
}