对接浦发银行支付(四)-- 支付回调接口

一、背景

和退款接口可能是同步不一样,支付必须是异步实现。在拉起支付后,等待用户输入密码,然后支付方最后回调我们对外提供的接口。

支付功能本身简单易懂,下面浦发银行和公司研发环境的交互流程:

我们需要开放一个对外的回调接口,便于浦发银行来回调,告知我们用户支付成功了,然后我们更新订单状态,进一步处理自己的业务逻辑。

具体到对接浦发银行,它的签名是在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。(对接的时候看文档,未联调,还不理解其回调报文怎么不返回呢)

相关推荐
2402_857589365 分钟前
SpringBoot框架:作业管理技术新解
java·spring boot·后端
HBryce248 分钟前
缓存-基础概念
java·缓存
一只爱打拳的程序猿23 分钟前
【Spring】更加简单的将对象存入Spring中并使用
java·后端·spring
杨荧25 分钟前
【JAVA毕业设计】基于Vue和SpringBoot的服装商城系统学科竞赛管理系统
java·开发语言·vue.js·spring boot·spring cloud·java-ee·kafka
阿伟*rui25 分钟前
认识微服务,微服务的拆分,服务治理(nacos注册中心,远程调用)
微服务·架构·firefox
minDuck26 分钟前
ruoyi-vue集成tianai-captcha验证码
java·前端·vue.js
为将者,自当识天晓地。1 小时前
c++多线程
java·开发语言
daqinzl1 小时前
java获取机器ip、mac
java·mac·ip
ZHOU西口1 小时前
微服务实战系列之玩转Docker(十八)
分布式·docker·云原生·架构·数据安全·etcd·rbac