一、背景
和退款接口可能是同步不一样,支付必须是异步实现。在拉起支付后,等待用户输入密码,然后支付方最后回调我们对外提供的接口。
支付功能本身简单易懂,下面浦发银行和公司研发环境的交互流程:
我们需要开放一个对外的回调接口,便于浦发银行来回调,告知我们用户支付成功了,然后我们更新订单状态,进一步处理自己的业务逻辑。
具体到对接浦发银行,它的签名是在htt header的"X-SPDB-SIGNATURE"中,body中的报文是加密的,待我们解密。
第二步,我们使用浦发行的公钥以及密文
二、开放支付回调接口
1、内外网穿透
把公司研发环境下的Kong机器通过端口映射到外网,定义一个端口。
2、Kong自定义插件
支付服务一共有许多接口,大多供内部调用,并不对外暴露。
kong作为api网关,开发一个自定义插件,按需暴露支付回调接口。
URL前缀是: http://{你的外网IP}:{端口}/pay
配置Kong自定义插件:
对外暴露的支付回调接口,特别需要注意安全,防止他人篡改报文。
所以才有加密和签名这两道墙,而不是明文的方式。
一旦被攻破了这两道防线,用户则无需真正支付,而让你的订单完成"支付"。
可真的是一分钱都不用花~ 慎重!!
3、定义支付服务的回调接口
支付服务定义一个接口:
bash
@PostMapping("/api/xxx/callback")
public String hzbankPayNotifyRes(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String spdbSign = new String(request.getHeader("X-SPDB-SIGNATURE").getBytes(), CharsetUtil.UTF_8);
String requestResultJson = IOUtils.toString(request.getInputStream(), request.getCharacterEncoding());
if (log.isInfoEnabled()) {
log.info("浦发银行支付回调通知, spdbSign={}, requestResultJson={}", spdbSign, requestResultJson);
}
// 略
}
三、浦发开放平台配置RSA密钥
这一段直接取自浦发银行的文档,熟悉配置的,可以跳过~
先新增"APP",点击"开发者公钥",选择"外调RSA公钥"。
RSA密钥对,可以在线生成,当然也可以把已有的赋值。
输入RSA公钥,上传的公钥需携带头部"-----BEGIN PUBLIC KEY-----"及尾部"-----END PUBLIC KEY-----",点击保存后, 等待几秒会自动回显浦发RSA公钥,用于验签。
作为程序开发,你需要保存好合作方公钥及私钥,公钥交给浦发,它会使用RSA公钥对支付回调的报文进行加密;私钥配置在支付服务的程序里,用于对支付回调的密文进行解密。
除此之外,你还需要保存并配置浦发公钥,它是用于签名的验证。
四、支付回调处理逻辑
bash
# 解密报文
String decryptBody = SpdbSignUtil.decryptStr(requestResultJson, rsaPrivatekey);
Map<String, Object> resultMap = JSON.parseObject(decryptBody, HashMap.class);
// 下面两个key是用来传递签名和明文,待验签用
resultMap.put(SpdbConfig.SIGN, spdbSign);
resultMap.put(SpdbConfig.NOTIFY_BODY, decryptBody);
// 处理支付订单
// 验证签名
SpdbSignUtil.verifySign(
MapUtil.getStr(resultMap, SpdbConfig.NOTIFY_BODY),
MapUtil.getStr(resultMap, SpdbConfig.SIGN),
spdbRsaPublickey));
1、报文解密
http request body是json格式,为了安全性,采用标准RSA方式加密,待我们解密。
具体的示例代码见下文,它使用hutool的crypto开源项目,本身没什么难度。
但是,我想要说的是:
浦发银行的支付回调设计不合理,不合理的点在于:解密需要RSA密钥,而RSA密钥是配置在浦发开放平台的APP下。
理论上每个APP可以配置不同的RSA密钥对,如果支付服务对接了多个浦发银行的商户,在解密的时候,是需要知道使用哪个商户的RSA密钥。
而这显然是无法做到的!!
我的建议是浦发银行在回调的时候,除签名字段外,把商户ID字段也通过http header传递给商户。
如下,我们便可以先取得商户ID,反查商户的RSA密钥,下一步才可以解密http body里的密文为明文。
现在的解决办法是:既然rsa密钥对是固定的值,直接在程序里配置。(如果硬是要配置在商户信息中,那就必须保证所有的商户共用一套RSA密钥对。)
2、验签
按下文给的验签示例,它依赖于支付回调接口中的签名(在http header中的字段"X-SPDB-SIGNATURE")
需要注意的是,验签的方法,除了签名外,另外一个参数是报文的明文,不是支付回调的密文。(所以它依赖于上一步的解密)
千万别把验签方法的入参搞错了,传递的是明文,而非密文。。。
3、代码示例
摘自浦发银行官方文档
bash
import cn.hutool.core.codec.Base64;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.asymmetric.KeyType;
import cn.hutool.crypto.asymmetric.RSA;
import cn.hutool.crypto.asymmetric.Sign;
import cn.hutool.crypto.asymmetric.SignAlgorithm;
import java.io.UnsupportedEncodingException;
public class Test {
/**
* 合作方RSA私钥
*/
private static final String MY_PRIVATE_KEY =
"MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALYaGizzujcBJpSf\n" +
"cUDBXe1Ovl6PPLqaNie+8bqAObbPefwDvJdn77BUiPUqomBLb+JdUej4VIQfmza9\n" +
"n3wWMw67Uq9AccCWeMpJNzzfMk42HaYZ0A3+foi/XUtZREkhXnH+3YKZ6k9fujfA\n" +
"VCPFHnwO/MJ5BVzHwSQC9jcS1oqjAgMBAAECgYBqvkI5t2SgeXw0AoJQgwib4lyU\n" +
"8UGX4G1+Pt9Tg3ZRQq0unMIfvj0yD1t42tTzvUSIXEt3VJm2GRDStbSW+CxUqOFW\n" +
"6zdHU1ySEn1rfAQVgWR7OEEyRo3gXXHycGNwHqNnPwuza8vLaxxoIRGMFUL2aVsc\n" +
"y6Td6ZMKkwJ3ndESAQJBAONtZFnZN7hgHmoWGfI5z1OPk/bK7tx6ibwQx7dx5jB2\n" +
"02Fn7Upd9ZSmP/DCPige+R8EN6m4VjOlCvPoR4TQDxECQQDM+vAwLVzC/QUe5Jan\n" +
"j/7m3CQSMpRCUjX0WzqUj76EBR1fYrHV5cI8r2bSRxEtKGBqk4esNgnJoLVTodtL\n" +
"b2ZzAkB/JSYoMQ88rcfzKT4CNJ2bKrbfD17wtjUQhhURksTNLXFJkI+RtuvX2gX/\n" +
"NKkJRx+hXns8EElpAAkaiS6KqsLxAkEAyXr20EQmY7sUZ3NE6ltNsFo+UmzI8g+g\n" +
"3Rk3EYPhPh9Q6cs3Bgqay8+U/6e/KGYBr4Bn4UwUfs2qrhPwW8uaJQJBAMrGBS7P\n" +
"gxsqS4XA4JN71B3zQkkWr/EIccK2T4q3hqRtUEtF8c9NShkFc10jlZ7hL+aDqPg4\n" +
"ZQ78AnvXnIW0aFA=";
/**
* 浦发RSA公钥
*/
private static final String SPDB_PUBLIC_KEY =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAh6sKYf0mpJuOEAavMi5p\n" +
"DCjBJUcXY85osHBoAwg37BMOCC6uLlGdssgjp9XgzSJ8iss8z3TT4lOXYqPEIx4c\n" +
"wAd62fXHGYWswbVAeDPMAlAKKiMElvSITO8C55Ui95AKo4xu5SHkytRYfJ2u0OD8\n" +
"r6G7dpdyXS8lb23PWOxpj0Xs+rlGjkfY9pDHS90CPNFhjnUATPpI0blzT/If1F0l\n" +
"GtOy6+ArIS8XaAij0topNlglangv5jBiNdTEP/U0mWH7TWAvG15cwoLSZMz/W+/S\n" +
"VrEmnm94WZaYIUtPuY6pdLgnPydsNAEOu6J+uY0J8uPKYY4XsrC8vLajFaeTGL24\n" +
"EQIDAQAB";
/**
* 验证签名
* @param decryptBody 报文明文
* @param spdbSign 浦发签名,一般放在请求头的"X-SPDB-SIGNATURE"字段中
* @return 是否验签通过
* @throws UnsupportedEncodingException
*/
public static boolean verifySign(String decryptBody, String spdbSign) throws UnsupportedEncodingException {
Sign sign = SecureUtil.sign(SignAlgorithm.SHA1withRSA, null, SPDB_PUBLIC_KEY);
boolean verify = sign.verify(decryptBody.getBytes("utf-8"), Base64.decode(spdbSign));
return verify;
}
/**
* 使用合作方私钥解密报文体
* @param encryptBody 报文密文
* @return 解密后的明文
*/
public static String decryptStr(String encryptBody) {
RSA rsa = new RSA(MY_PRIVATE_KEY, null);
String decryptStr = rsa.decryptStr(encryptBody, KeyType.PrivateKey);
return decryptStr;
}
public static void main(String[] args) throws UnsupportedEncodingException {
//示例解密
String body = "H8dzLP11ulqDeC7neeJ8OP1SmFqtkQxEVb3hJku8FylrpriTAQATa2+y+QQcEDcbm0GnnTaO7iWivbb1I1cjowdj9Tn+pRYicOhplrRIX63KzOmVcBW4LslvRf2olEf1qt0rvj38rIPZMLX7k6RlCmfwTYxYOdkYce8t+N0mzSEtZUyLiyKP2C2EtTsKD2feA0r0SabW/z4E62n5WhO0NK3eBsbzCodXUHYyUyJ4Qp5rq5ol8wJ6mzgJ1bDvQZXHs3AGGzdUgy/+7zQyBsnBfMaMnEVa+s/cDWL8lD2jw46pQeattUvfkpBeqaMH/iRy/q1gmeHCXrTcQVJUfMEPhGdpcr4XSqabainIQDNK79qcfCxU/zCrKaKB17de0G+5BSAhlJ9ZM6mnFHyatYlcCnBYSz5/x0QuPZiUOcDfRoa6jbc5mW5egph48d+O0wRM3USrFrWBGSWI7liMEV6OvJaQXqQyOOeLQhTS5cPfXnxIf0bo+6VvCEdMQMJ2kFlH";
String decryptBody = decryptStr(body);
System.out.println("解密结果:");
System.out.println(decryptBody);
//示例验签
String sign = "T09EE/ofLiDemcNazhaqNp5a9oH9MOR/aNA3B7Vz/3+qUo//92+VPs/Sn31XFWNHA3KjDuwesTIVwXxvb+XgUxhPCK/AXpFMVqbL3Tw3BL9QpvS9Q+ZsCtM+X52bOB66N9dPNVjZUYnwt2SU704zk4XZFH/CMDP8XLvLdvnoPjyFS532yOKRLidRb3fKKseIRs8bUxmhKb4S7NHIursGQLlzQuPWxzlTHbBAbZJEZNWgO0XWAT0sLcGmQInrR7ujmiB/D4CiGkMp9mtca1B2MBs31eQboLb9+uRtM9kGsHxw/nV/MQJ/ZzBcwC1c7XDnnbJCIA0LRaLlnAYVOg6eiA==";
System.out.println("验签结果:");
System.out.println(verifySign(decryptBody, sign));
}
}
支付账单
从该账单详情,可以看到以下信息:
- 4200开头的交易单号是微信的支付流水号,这是微信和浦发银行对账的依据。
- 1901开头的商户单号是浦发银行的支付流水号,这将是我们和浦发银行对账的依据。
不会展示我们平台的支付流水号,因为我们对于微信来说,是透明的,微信只认浦发银行。
当用户对于支付账单由疑问的时候,客户可以基于这个账单中1901开头的商户单号,要能够反查出我们商城的业务订单信息以及支付订单信息。
五、总结
鉴于上面的明文和密文都是一个示例,不是浦发实际的参数。
最后给出真实的报文及签名内容:
- 支付回调的签名
bash
eoClolWapK8NZp8lxgA9ED74zrfVUwbJND5vBYpS/MnefVDqrrA+72hhuYTLcfIZo6R41/MSdBfrGlPccHX3JN/jCIG18wo59NNRt6T83timCZZefgZKOVJOMYie3mBK9A/NDHqSHhRT24xxxUH3PvfKOb7/3svMvuWkEK3I/tzyb72Qkqz5InfH3S55VLb+P9GZGkM6kmUin1dhhCC67kHkTbxc9bX7ry+lDShwsXqfgbaHdTw/iF+CVE3795Zn53W1qGWvI1oJUWcko3zAcxAKTy0CEdQRWMSWP+ZsmtRp3Q8px75hRHTcKEeY+UBtvis9vNpZoW4dAbTU5ZSVtg==,
- 支付回调的密文
bash
gDrBttO3tKx49tDC6KZN+XdX47rmx0Z28+iUy4rL0j7+KH5LnyVXFIBYoFwmR5NoQUJV49vYbC7vZwCoAkH6WYocyKoDuv2xUpEbwkb/XBQEMPIMoiHhPuixnzxvPQRM7vQDEg4eI5FnYqtYO4wgh7RoZJfEPBeZ9wQHl0G5pLIxDJTs/LNC16FzG1St5CA0zcz9ttkRJZdyBaAzis+8/AaDmv6GxqLin/nlqxHoZCgTxUN5+xMvSkV9pM17Y/NQfLexE6fy1ooqZ1Am5FwbYVfU0UtEWOhZf2JAhT3b956imVBpo7hK6uQymNLG4/temwdvlYg1QFtE31wFJZ9k2m9GBL17ll1F7jdENzoibHtIrGoYIQr1fwb16P7FXTfDb0fT3+eJ20vqHKaLi0oJOmXGzB6zqAwqxCnQQ9YVVJXUjqFPvF49AZKh1ZaJ5s9bYQyuprfAXsy4YQizFyOTmmncK9/R2JiUuK5b6qFnk2UPqtnDxoXuiewk1DSi2lPgelN1IUPe8HBoctO5htbgnXN5RbjiXvx7DfvPafoD/MovwbSrKCooqt1Iww/kL8R2RQeymrdcb5MXxfg9Koap2kcQv873fOh5u0MhwqgcNYytp49eYFI61naTrVikGIYnK2zdupBTHOvpulkAy9nVzrLrQCmhpbhqbpRgvBa5B+4=
- 解密后的明文:
bash
{
"TransTime": "20240417171758",
"SysSeq": "061632",
"Channel": "1A31",
"TransAmt4": "000000000001",
"ChlTp": "1",
"MrchOrdrNo3": "0624041717B1815018507",
"Ccy4": "156",
"OrdrNo1": "1901041717172200168952061726",
"ThdPtySeq": "4200002171202404172188555631",
"OrdrSt": "00",
"TransDate": "20240417",
"PyBnkInfo": "OTHERS",
"TranTp1": "OA",
"MrchNo": "310319982990001",
"TerminalNo2": "98A00162"
}
返回报文的有用字段是:
{ # 支付时间
"TransTime": "20240417171758",
支付金额
"TransAmt4": "000000000001",
平台支付流水号(商户支付流水号)
"MrchOrdrNo3": "0624041717B1815018507",
浦发银行支付流水号
"OrdrNo1": "1901041717172200168952061726"
}
程序以浦发银行支付流水号为准,查询支付订单,进而更新订单状态和实际付款时间。
顺便吐槽一下其文档,如果平常开发中有人这样定义字段,不被喷才怪。。
要理解其字段的内涵,必须结合报文内容,才能对得上。
浦发并没有提供代码,这导致你在写代码的时候,字段名都不敢手敲,极容易写错字段名。
最后,我说下,文档里没找到支付时间TransTime。(对接的时候看文档,未联调,还不理解其回调报文怎么不返回呢)