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

一、背景

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

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

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

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

相关推荐
考虑考虑25 分钟前
JDK9中的dropWhile
java·后端·java ee
想躺平的咸鱼干33 分钟前
Volatile解决指令重排和单例模式
java·开发语言·单例模式·线程·并发编程
秋千码途1 小时前
小架构step系列06:编译配置
架构
hqxstudying1 小时前
java依赖注入方法
java·spring·log4j·ioc·依赖
·云扬·1 小时前
【Java源码阅读系列37】深度解读Java BufferedReader 源码
java·开发语言
martinzh2 小时前
Spring AI 项目介绍
后端
Bug退退退1232 小时前
RabbitMQ 高级特性之重试机制
java·分布式·spring·rabbitmq
小皮侠2 小时前
nginx的使用
java·运维·服务器·前端·git·nginx·github
打好高远球2 小时前
如何用AI破解相亲信息不对称
架构
前端付豪2 小时前
20、用 Python + API 打造终端天气预报工具(支持城市查询、天气图标、美化输出🧊
后端·python