跳至主要內容

云商城-微信支付

soulballad实践项目SpringCloudAlibaba 云商城SpringCloudAlibaba约 11777 字大约 39 分钟

第8章 微信支付/退款

课程目标

1、密码安全学

​ 1)摘要加密

​ 2)Base64

​ 3)对称加密

2、微信支付

​ 1)微信支付模式

​ 2)SDK初始化操作

​ 3)支付统一下单

​ 4)支付结果查询

​ 5)花生壳使用(自学)

​ 6)支付通知接收

3、微信退款

​ 1)退款流程分析

​ 2)退款申请操作

​ 3)退款结果处理

1 数据/密码安全学

1.1 摘要加密

​ 摘要数据:47bce5c74f589f4867dbd57e9ca9f808

​ 摘要是哈希值,我们通过散列算法比如MD5算法就可以得到这个哈希值。摘要只是用于验证数据完整性和唯一性的哈希值,不管原始数据是什么样的,得到的哈希值都是固定长度的。
​ 不管原始数据是什么样的,得到的哈希值都是固定长度的,也就是说摘要并不是原始数据加密后的密文,只是一个验证身份的令牌。所以我们无法通过摘要解密得到原始数据。

​ 常用的摘要算法有:MD5算法(MD2 、MD4、MD5),SHA算法(SHA1、SHA256、SHA384、SHA512),HMAC算法

​ 摘要加密算法特性:

​ 1:任何数据加密,得到的密文长度固定。

​ 2:密文是无法解密的(不可逆)。

1.1.1 MD5

1606807583440

​ MD5信息摘要算法(英语:MD5 Message-Digest Algorithm),一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值(hash value),用于确保信息传输完整一致。MD5由美国密码学家罗纳德·李维斯特(Ronald Linn Rivest)设计,于1992年公开,用以取代MD4算法。这套算法的程序在 RFC 1321 标准中被加以规范。1996年后该算法被证实存在弱点,可以被加以破解,对于需要高度安全性的数据,专家一般建议改用其他算法,如SHA-2。2004年,证实MD5算法无法防止碰撞(collision),因此不适用于安全性认证,如SSL公开密钥认证或是数字签名等用途。

​ MD5存在一个缺陷,只要明文相同,那么生成的MD5码就相同,于是攻击者就可以通过撞库的方式来破解出明文。加盐就是向明文中加入指定字符,主要用于混淆用户、并且增加MD5撞库破解难度,这样一来即使撞库破解,知道了明文,但明文也是混淆了的,真正需要用到的数据也需要从明文中摘取,摘取范围、长度、摘取方式都是个谜,如此一来就大大增加了暴力破解的难度,使其几乎不可能破解。

我们来编写一个MD5案例,在mall-common中编写com.gupaoedu.mall.util.MD5,代码如下:

public class MD5 {

    /**
     * MD5方法
     * @param text 明文
     * @return 密文
     * @throws Exception
     */
    public static String md5(String text) throws Exception {
        //加密后的字符串
        String encode= DigestUtils.md5Hex(text);
        return encode;
    }

    /**
     * MD5方法
     * @param text 明文
     * @param key 盐
     * @return 密文
     * @throws Exception
     */
    public static String md5(String text, String key) throws Exception {
        //加密后的字符串
        String encode= DigestUtils.md5Hex(text + key);
        return encode;
    }

    /**
     * MD5验证方法
     * @param text 明文
     * @param key 密钥
     * @param md5 密文
     * @return true/false
     * @throws Exception
     */
    public static boolean verify(String text, String key, String md5) throws Exception {
        //根据传入的密钥进行验证
        String md5Text = md5(text, key);
        return md5Text.equalsIgnoreCase(md5);
    }
}

引入依赖包:

<dependencies>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
    </dependency>

    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
    </dependency>
</dependencies>

1.1.2 验签

验签其实就是签名验证,MD5加密算法经常用于签名安全验证。关于验签,我们用下面这个流程图来说明:

1606872895843

如上图:

1:mall-order-service向mall-pay-service服务发送数据前,先对数据进行处理。
2:先把数据封装到Map中,再对数据进行排序。
3:获取排序后的数据的MD5只,并将MD5只封装到Map中。
4:把带有MD5只的Map传给mall-pay-service。
5:mall-pay-service中获取到数据,移除Map中的MD5值,再将Map排序。
6:获取排序后的MD5值,并且对比传过来的MD5值。
7:两个MD5值如果一样,证明该数据安全,没有被修改,如果不一样,证明数据被修改了。

1.2 Base64

​ Base64是网络上最常见的用于传输8Bit字节码的编码方式之一,Base64就是一种基于64个可打印字符来表示二进制数据的方法。
​ Base64编码是从二进制到字符的过程,可用于在HTTP环境下传递较长的标识信息。采用Base64编码具有不可读性,需要解码后才能阅读。
​ Base64由于以上优点被广泛应用于计算机的各个领域,然而由于输出内容中包括两个以上“符号类”字符(+, /, =),不同的应用场景又分别研制了Base64的各种“变种”。为统一和规范化Base64的输出,Base62x被视为无符号化的改进版本,但Base62x的性能效率偏低,目前还不建议在项目中使用。

​ 标准的Base64并不适合直接放在URL里传输,因为URL编码器会把标准Base64中的“/”和“+”字符变为形如“%XX”的形式,而这些“%”号在存入数据库时还需要再进行转换,因为ANSI SQL中已将“%”号用作通配符。
​ 为解决此问题,可采用一种用于URL的改进Base64编码,它在末尾填充'='号,并将标准Base64中的“+”和“/”分别改成了“-”和“_”,这样就免去了在URL编解码和数据库存储时所要作的转换,避免了编码信息长度在此过程中的增加,并统一了数据库、表单等处对象标识符的格式。

我们在mall-common中创建com.gupaoedu.mall.util.Base64Util,在该类中演示Base64操作,代码如下:

public class Base64Util {

    /***
     * 普通解密操作
     * @param encodedText
     * @return
     */
    public static byte[] decode(String encodedText){
        final Base64.Decoder decoder = Base64.getDecoder();
        return decoder.decode(encodedText);
    }

    /***
     * 普通加密操作
     * @param data
     * @return
     */
    public static String encode(byte[] data){
        final Base64.Encoder encoder = Base64.getEncoder();
        return encoder.encodeToString(data);
    }

    /***
     * 解密操作
     * @param encodedText
     * @return
     */
    public static byte[] decodeURL(String encodedText){
        final Base64.Decoder decoder = Base64.getUrlDecoder();
        return decoder.decode(encodedText);
    }

    /***
     * 加密操作
     * @param data
     * @return
     */
    public static String encodeURL(byte[] data){
        final Base64.Encoder encoder = Base64.getUrlEncoder();
        return encoder.encodeToString(data);
    }
}

1.2 对称加密

1606808802482

​ 前面我们学习了MD5,MD5加密后本质上是无法解密,是一个不可逆的过程,而网上有很多解密其实都是一种穷举法对比,根本不存在破解方法。

​ 在业务中,很多时候存在解密的需要,我们可以采用对称加密,对称加密是指加密和解密都采用相同的秘钥。使用对称加密,发送方使用密钥将明文数据加密成密文,然后发送出去,接收方收到密文后,使用同一个密钥将密文解密成明文读取,我们可以用一个很形象的例子来解释对称加密,例如:只有一模一样的钥匙才能打开同一个锁,也只有那把钥匙能锁住那把锁。

1.2.1 AES详解

​ 典型的对称加密算法有DES、3DES、AES,但AES加密算法的安全性要高于DES和3DES,所以AES已经成为了主要的对称加密算法。

​ AES加密算法就是众多对称加密算法中的一种,它的英文全称是Advanced Encryption Standard,翻译过来是高级加密标准,它是用来替代之前的DES加密算法的。

​ 要理解AES的加密流程,会涉及到AES加密的五个关键词,分别是:分组密码体制Padding密钥初始向量IV四种加密模式,下面我们一一介绍。

分组密码体制:所谓分组密码体制就是指将明文切成一段一段的来加密,然后再把一段一段的密文拼起来形成最终密文的加密方式。AES采用分组密码体制,即AES加密会首先把明文切成一段一段的,而且每段数据的长度要求必须是128位16个字节,如果最后一段不够16个字节了,就需要用Padding来把这段数据填满16个字节,然后分别对每段数据进行加密,最后再把每段加密数据拼起来形成最终的密文。

Padding:Padding就是用来把不满16个字节的分组数据填满16个字节用的,它有三种模式PKCS5、PKCS7和NOPADDING。PKCS5是指分组数据缺少几个字节,就在数据的末尾填充几个字节的几,比如缺少5个字节,就在末尾填充5个字节的5。PKCS7是指分组数据缺少几个字节,就在数据的末尾填充几个字节的0,比如缺少7个字节,就在末尾填充7个字节的0。NoPadding是指不需要填充,也就是说数据的发送方肯定会保证最后一段数据也正好是16个字节。那如果在PKCS5模式下,最后一段数据的内容刚好就是16个16怎么办?那解密端就不知道这一段数据到底是有效数据还是填充数据了,因此对于这种情况,PKCS5模式会自动帮我们在最后一段数据后再添加16个字节的数据,而且填充数据也是16个16,这样解密段就能知道谁是有效数据谁是填充数据了。PKCS7最后一段数据的内容是16个0,也是同样的道理。解密端需要使用和加密端同样的Padding模式,才能准确的识别有效数据和填充数据。我们开发通常采用PKCS7 Padding模式。

初始向量IV:初始向量IV的作用是使加密更加安全可靠,我们使用AES加密时需要主动提供初始向量,而且只需要提供一个初始向量就够了,后面每段数据的加密向量都是前面一段的密文。初始向量IV的长度规定为128位16个字节,初始向量的来源为随机生成。至于为什么初始向量能使加密更安全可靠。

密钥:AES要求密钥的长度可以是128位16个字节、192位或者256位,位数越高,加密强度自然越大,但是加密的效率自然会低一些,因此要做好衡量。我们开发通常采用128位16个字节的密钥,我们使用AES加密时需要主动提供密钥,而且只需要提供一个密钥就够了,每段数据加密使用的都是这一个密钥,密钥来源为随机生成。

四种加密模式:AES一共有四种加密模式,分别是ECB(电子密码本模式)CBC(密码分组链接模式)CFBOFB我们一般使用的是ECB和CBC模式。四种模式中除了ECB相对不安全之外,其它三种模式的区别并没有那么大,因此这里只会对ECB和CBC模式做一下对比,看看它们在做什么。

1606811351128

ECB模式是最基本的加密模式,即仅仅使用明文和密钥来加密数据,相同的明文块会被加密成相同的密文块,这样明文和密文的结构将是完全一样的,就会更容易被破解,相对来说不是那么安全,因此很少使用。

1606811814758

CBC模式则比ECB模式多了一个初始向量IV,加密的时候,第一个明文块会首先和初始向量IV做异或操作,然后再经过密钥加密,然后第一个密文块又会作为第二个明文块的加密向量来异或,依次类推下去,这样相同的明文块加密出的密文块就是不同的,明文的结构和密文的结构也将是不同的,因此更加安全。

1.2.2 AES算法下载

java 中的 AES 秘钥为256bit 算法执行时,会遇到 Illegal key size or default parameters 错,原因是因为本地没有对应的算法库,需要下载对应JDK版本的算法库。

JDK8 jar 包下载地址:

https://www.oracle.com/java/technologies/javase-jce8-downloads.html

JDK7 jar 包下载地址:

https://www.oracle.com/java/technologies/javase-jce7-downloads.html

JDK6 jar 包下载地址:

https://www.oracle.com/java/technologies/jce-6-download.html

下载后解压,可以看到local_policy.jarUS_export_policy.jar以及readme.txt
如果安装了JRE,将两个jar文件放到%JRE_HOME%\lib\security目录下覆盖原来的文件。
如果安装了JDK,还要将两个jar文件也放到%JDK_HOME%\jre\lib\security目录下覆盖原来文件。

1.2.3 AES实战

使用AES加密、解密,他们的执行过程都是一样的,步骤如下:

1:加载加密解密算法处理对象(包含算法、秘钥管理)
2:根据不同算法创建秘钥
3:设置加密模式(无论是加密还是解析,模式一致)
4:初始化加密配置
5:执行加密/解密

mall-common中引入算法依赖包

<!--算法依赖包-->
<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk16</artifactId>
    <version>1.46</version>
</dependency>

我们编写一个类com.gupaoedu.mall.util.AESUtil,既可以实现加密,也可以实现解密,代码如下:

/****
 * AES加密
 * @param buffer : 明文/密文
 * @param appsecret : 秘钥,16位
 * @param mode : 处理方式  1:加密,2:解密
 */
public static byte[] encryptAndDecrypt(byte[] buffer, String appsecret,Integer mode) throws Exception {
    //1、加载加密处理对象,该对象会提供加密算法、秘钥生成、秘钥转换、秘钥管理等功能
    Security.addProvider(new BouncyCastleProvider());
    //2、创建秘钥对象,并指定算法
    SecretKeySpec secretKey = new SecretKeySpec(appsecret.getBytes("UTF-8"),"AES");
    //3、设置Cipher的加密模式,AES/ECB/PKCS7Padding  BC指定算法对象(BouncyCastleProvider)
    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS7Padding","BC");
    //4、初始化加密配置
    cipher.init(mode,secretKey);
    //5、执行加密/解密
    return cipher.doFinal(buffer);
}

此处的秘钥为16byte(128bit),秘钥的长度可以为128/192/256 bit,我们测试如下:

public static void main(String[] args) throws Exception {
    //明文
    String content = "SpringCloud Alibaba!";
    //16位秘钥
    String key ="1616161616161616";

    //加密
    byte[] encrypt = encryptAndDecrypt(content.getBytes("UTF-8"), key, 1);
    System.out.println("加密后的密文:"+Base64Util.encode(encrypt));
    //解密
    byte[] decrypt = encryptAndDecrypt(encrypt, key, 2);
    System.out.println(new String(decrypt,"UTF-8"));
}

测试效果如下:

加密后的密文:y5wS5hE9rAPUu2//IDma20JhTWzekMCJ0+E0GP5xmaQ=
SpringCloud Alibaba!

我们如果使用256bit的秘钥,可以将当前秘钥转换成MD5值,MD5值长度为32,大小为256bit,正好满足256bit的秘钥,测试代码如下:

1606870964621

测试效果如下:

加密后的密文:B3s+4boCopYMEZKsFMue9UFBMdjkrNCyE2sMS9KXWEk=
SpringCloud Alibaba!

2 微信扫码支付

1606457463740

​ 微信支付是腾讯公司的支付业务品牌,微信支付商户平台支持线下场所、公众号、小程序、PC网站、APP、企业微信等经营场景快速接入微信支付。微信支付全面打通O2O生活消费领域,提供专业的互联网+行业解决方案,微信支付支持微信红包和微信理财通,是移动支付的首选。

接口文档地址:https://pay.weixin.qq.com/wiki/doc/api/index.html

1606457602953

​ 我们将在项目中采用微信支付的方式实现支付,并且采用微信支付当前主流的二维码扫码支付方式。

2.1 支付模式

​ 如上图,是微信扫码支付的模式之一,网址:https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=6_5,商户后台系统先调用微信支付的统一下单接口,微信后台系统返回链接参数code_url,商户后台系统将code_url值生成二维码图片,用户使用微信客户端扫码后发起支付。注意:code_url有效期为2小时,过期后扫码不能再发起支付。

流程说明:

1:商户后台系统根据用户选购的商品生成订单。
2:用户确认支付后调用微信支付【统一下单API】生成预支付交易;
3:微信支付系统收到请求后生成预支付交易单,并返回交易会话的二维码链接code_url。
4:商户后台系统根据返回的code_url生成二维码。
5:用户打开微信“扫一扫”扫描二维码,微信客户端将扫码内容发送到微信支付系统。
6:微信支付系统收到客户端请求,验证链接有效性后发起用户支付,要求用户授权。
7:用户在微信客户端输入密码,确认支付后,微信客户端提交授权。
8:微信支付系统根据用户授权完成支付交易。
9:微信支付系统完成支付交易后给微信客户端返回交易结果,并将交易结果通过短信、微信消息提示用户。微信客户端展示支付交易结果页面。
10:微信支付系统通过发送异步消息通知商户后台系统支付结果。商户后台系统需回复接收情况,通知微信后台系统不再发送该单的支付通知。
11:未收到支付通知的情况,商户后台系统调用【查询订单API】。
12:商户确认订单已支付后给用户发货。

2.2 SDK使用

1606458473714

​ 微信官方提供了支付SDK,如上图,我们可以点击下载SDK,再安装到本地并引入依赖。创建自己的实现类WeixinPayConfig继承WXPayconfig抽象类时会提示must either be declared abstract or implement abstract method ‘getAppID()’ in 'WXPayConfig问题。

​ 官方的wxpay-sdk源码中WXPayconfig抽象类的抽象方法没有指定public修饰符,则使用的是默认的修饰符default,default修饰符在其他包内是没有访问权限的,所以我们无法继承WXPayconfig抽象类实现抽象方法。解决方案是通过修改官方wxpay-sdk源码,将抽象方法的修饰符从默认修改为public,然后重新打包到本地或者私服,解决该问题。

我们可以通过以下表来说明访问权限:

修饰符当前类同一包内子孙类(同一包)子孙类(不同包)其他包
publicYYYYY
protectedYYYY/NN
defaultYYYNN
privateYNNNN

修改WXPayConfig,代码如下:

1606521212858

打包并安装mvn clean install,在mall-pay-service中引入坐标:

<dependency>
    <groupId>com.github.wxpay</groupId>
    <artifactId>wxpay-sdk</artifactId>
    <version>3.0.9</version>
</dependency>

​ 关于微信支付的使用,微信官方提供的包中已经附带了使用说明,在参考资料中有微信支付 Java SDK使用说明.md文档,可以按照该文档学习SDK的使用。

微信支付SDK提供了对应的方法:

方法名说明
microPay刷卡支付
unifiedOrder统一下单
orderQuery查询订单
reverse撤销订单
closeOrder关闭订单
refund申请退款
refundQuery查询退款
downloadBill下载对账单
report交易保障
shortUrl转换短链接
authCodeToOpenid授权码查询openid

我们将参考资料里面的微信支付\证书和秘钥中的证书以及秘钥配置导入到mall-pay-service工程中来,如下图:

1606521363290

注意:如果是php开发,采用pem证书,如果是java开发采用p12证书。

做退款的时候要用到证书,我们需要使用WXCertUtil.exe导出证书。导出证书的说明网址:https://kf.qq.com/faq/161222NneAJf161222U7fARv.html。

2.3 SDK身份信息初始化

我们将秘钥、商户ID、证书等信息导入到程序中,修改mall-pay-service配置文件,并创建WeixinPayConfig加载配置文件中的秘钥、商户ID、证书等 信息。

bootstrap.yml添加如下配置:

#支付配置
payconfig:
  weixin:
    #应用ID
    appId: wx9f1fa58451efa9b2
    #商户ID号
    mchID: 157请自行申请561
    #秘钥
    key: QS8rrOI出于安全考虑,请自行申请TQCfI1
    #默认回调地址
    notifyUrl: http://2cw4969042.wicp.vip:50381/wx/result
    #证书存储路径
    certPath: D:/alibaba/dev/shop/gupaoedu-vip-mall/mall-service/mall-pay-service/src/main/resources/apiclient_cert.p12
  #支付安全校验(验签)
  aes:
    #AES加密秘钥
    skey: ab2cc473d3334c39
    #验签盐
    salt: XPYQZb1kMES8HNaJWW8+TDu/4JdBK4owsU9eXCXZDOI=

创建com.gupaoedu.vip.mall.pay.config.WeixinPayConfig加载微信支付配置信息:

@Component
public class WeixinPayConfig extends WXPayConfig {

    //微信支付信息
    @Value("${payconfig.weixin.appId}")
    private String appId;       //应用ID
    @Value("${payconfig.weixin.mchID}")
    private String mchID;       //商户号
    @Value("${payconfig.weixin.key}")
    private String key;         //秘钥
    @Value("${payconfig.weixin.notifyUrl}")
    private String notifyUrl;   //回调地址
    @Value("${payconfig.weixin.certPath}")
    private String certPath;    //证书路径
    //证书字节数组
    private byte[] certData;

    @Override
    public String getAppID() {
        return this.appId;
    }

    @Override
    public String getMchID() {
        return this.mchID;
    }

    @Override
    public String getKey() {
        return this.key;
    }

    /***
     * 获取商户证书内容
     * @return
     */
    @Override
    public InputStream getCertStream() {
        /****
         * 加载证书
         */
        if(certData==null){
            synchronized (WeixinPayConfig.class){
                try {
                    if(certData==null) {
                        File file = new File(certPath);
                        InputStream certStream = new FileInputStream(file);
                        this.certData = new byte[(int) file.length()];
                        certStream.read(this.certData);
                        certStream.close();
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
        ByteArrayInputStream certBis = new ByteArrayInputStream(this.certData);
        return certBis;
    }

    /***
     * 获取WXPayDomain, 用于多域名容灾自动切换
     * @return
     */
    @Override
    public IWXPayDomain getWXPayDomain() {
        // 这个方法需要这样实现, 否则无法正常初始化WXPay
        IWXPayDomain iwxPayDomain = new IWXPayDomain() {
            @Override
            public void report(String domain, long elapsedTimeMillis, Exception ex) {
            }
            @Override
            public DomainInfo getDomain(WXPayConfig config) {
                return new IWXPayDomain.DomainInfo(WXPayConstants.DOMAIN_API, true);
            }
        };
        return iwxPayDomain;
    }
}

此时我们可以开始使用微信支付了。

2.4 支付下单

​ 支付流程如上图,用户下单后会调用支付系统执行预支付下单(统一下单)获取二维码的链接地址。此时是从客户端向支付系统发起预支付下单,预支付下单需要调用微信支付统一下单接口。

2.4.1 统一下单

​ 统一下单的API地址https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=9_1,该地址详细讲解了统一下单接口所需参数以及返回数据,我们参考微信支付 Java SDK使用说明.md中使用SDK实现统一下单操作。

​ 使用微信支付SDK我们需要先创建WXPay的实例并交给Spring容器管理,修改com.gupaoedu.vip.mall.MallPayApplication添加如下方法:

/****
 * 微信支付SDK对象
 * @param weixinPayConfig
 * @return
 * @throws Exception
 */
@Bean
public WXPay wxPay(WeixinPayConfig weixinPayConfig) throws Exception {
    return new WXPay(weixinPayConfig);
}

1)Service

接口:创建com.gupaoedu.vip.mall.pay.service.WeixinPayService,创建统一下单方法

public interface WeixinPayService {

    /***
     * 统一下单,获取支付二维码
     */
    Map<String,String> preOrder(Map<String,String> dataMap) throws Exception;
}

实现类:创建com.gupaoedu.vip.mall.pay.service.impl.WeixinPayServiceImpl实现统一下单

@Service
public class WeixinPayServiceImpl implements WeixinPayService {

    @Autowired
    private WXPay wxPay;

    /***
     * 统一下单,获取支付二维码
     */
    @Override
    public Map<String, String> preOrder(Map<String, String> dataMap) throws Exception {
        Map<String, String> resp = wxPay.unifiedOrder(dataMap);
        return resp;
    }
}

2)Controller

创建com.gupaoedu.vip.mall.pay.controller.WeixinPayController,并创建支付预下单方法,代码如下:

@RestController
@RequestMapping(value = "/wx")
@CrossOrigin
public class WeixinPayController {

    @Autowired
    private WeixinPayService weixinPayService;

    /*****
     * 预下单
     */
    @GetMapping(value = "/pay")
    public RespResult<Map> pay(@RequestParam Map<String,String> map) throws Exception {
        //1分钱测试
        if(map!=null){
            Map<String, String> resultMap = weixinPayService.preOrder(map);
            resultMap.put("orderNumber",map.get("out_trade_no"));
            resultMap.put("money",map.get("total_fee"));
            return RespResult.ok(resultMap);
        }
        return RespResult.error("支付系统繁忙,请稍后再试!");
    }

}

请求测试(请求路径中的参数是订单信息,可以参考统一下单API):http://localhost:8090/wx/pay?out_trade_no=ooopppuuuu&device_info=aa&total_fee=1&spbill_create_ip=127.0.0.1&notify_url=aa&trade_type=NATIVE&body=aa

效果如下:

{
    "code": 20000,
    "data": {
        "nonce_str": "wJrO7WF8ilj94PjA",
        "orderNumber": "ooopppuuuu",
        "sign": "56604E571F782AD0194249EAC3C09EEB",
        "return_msg": "OK",
        "mch_id": "1576040561",
        "prepay_id": "wx271508177677061a80be4139d5dd1f0000",
        "device_info": "aa",
        "money": "1",
        "code_url": "weixin://wxpay/bizpayurl?pr=EDSssZM00",
        "appid": "wx9f1fa58451efa9b2",
        "trade_type": "NATIVE",
        "result_code": "SUCCESS",
        "return_code": "SUCCESS"
    },
    "message": "操作成功"
}

我们可以把code_url复制到微信支付\二维码测试\wxpay.html中,打开后扫码支付,效果如下:

1606461251559

2.4.2 支付安全验签流程

1606461742012

​ 支付过程如上图,用户发起预支付下单,会将订单信息转成支付信息,并将支付信息传给支付服务,支付服务再调用微信服务器实现预下单操作。

​ 如果当前用户是一个程序员,能读懂一些代码,此时会存在很多隐患问题,如果用户把支付金额修改了,就会给公司造成很大的损失,此时我们需要对数据进行安全处理。

验签:

​ 需要传给支付服务器的数据,我们使用订单服务加密处理再返回到客户端,客户端将加密数据传给支付服务器,支付服务器对数据进行安全校验,校验通过了再下单,这个过程就是金融行业常用的验签操作。整个操作流程如下:

1606463037794

2.4.3 验签实现

参考资料微信支付\加密工具包目录下的AESUtil.javaMD5.javaSignature.java导入到mall-common工程的com.gupaoedu.mall.util包下。

同时记得导入FastJSON依赖包

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.51</version>
</dependency>

1)初始化Signature

mall-order-service工程的bootstrap.yml中添加秘钥配置和加盐配置,代码如下:

#支付配置
payconfig:
  #支付安全校验
  aes:
    #AES加密秘钥
    skey: ab2cc473d3334c39
    #验签盐
    salt: XPYQZb1kMES8HNaJWW8+TDu/4JdBK4owsU9eXCXZDOI=

mall-order-service工程中创建com.gupaoedu.vip.mall.order.config.SecurityConfig用于初始化Signature,代码如下:

@Configuration
public class SecurityConfig {

    //秘钥
    @Value("${payconfig.aes.skey}")
    private String skey;

    //验签加盐值
    @Value("${payconfig.aes.salt}")
    private String salt;

    /***
     * 验签对象
     */
    @Bean(value = "signature")
    public Signature signature(){
        return new Signature(skey,salt);
    }
}

2)支付参数处理

mall-order-service工程中创建com.gupaoedu.vip.mall.order.pay.WeixinPayParam,将订单信息封装到统一下单的参数中,并使用Signature进行签名和加密,代码如下:

@Component
public class WeixinPayParam {

    @Autowired
    private Signature signature;

    /****
     * 微信支付参数封装
     * 对参数进行签名
     * 对整体参数进行加密
     * @return
     */
    public String weixinParam(Order order, HttpServletRequest request) throws Exception {
        //定义Map封装参数
        Map<String,String> dataMap = new HashMap<String,String>();
        dataMap.put("body", "商城订单-"+order.getId());
        dataMap.put("out_trade_no", order.getId());
        dataMap.put("device_info", "PC");
        dataMap.put("fee_type", "CNY");
        //dataMap.put("total_fee", String.valueOf(order.getMoneys()));
        dataMap.put("total_fee", "1"); //1分钱测试
        dataMap.put("spbill_create_ip", IPUtils.getIpAddr(request));
        dataMap.put("notify_url", "http://www.example.com/wxpay/notify");
        dataMap.put("trade_type", "NATIVE");  // 此处指定为扫码支付
        //生成签名,并且参数加密
        return signature.security(dataMap);
    }
}

修改mall-order-service在该工程中com.gupaoedu.vip.mall.order.controller.OrderController里添加方法里增加支付数据处理,代码如下:

1606464175437

代码如下:

@Autowired
private WeixinPayParam weixinPayParam;

/***
 * 添加订单
 */
@PostMapping
public RespResult add(@RequestBody Order order,HttpServletRequest request) throws Exception {
    String userName = "gp";
    order.setUsername(userName);
    order.setCreateTime(new Date());
    order.setUpdateTime(order.getCreateTime());
    order.setId(IdWorker.getIdStr());
    order.setOrderStatus(0);
    order.setPayStatus(0);
    order.setIsDelete(0);
    //添加订单
    Boolean bo = orderService.add(order);
    //支付信息封装
    if(bo){
        //加密字符
        String ciptext = weixinPayParam.weixinParam(order,request);
        return RespResult.ok(ciptext);
    }
    return RespResult.error(RespCode.SYSTEM_ERROR);
}

3)数据验证

mall-pay-service工程中初始化Signature,将工程mall-order-service中的com.gupaoedu.vip.mall.order.config.SecurityConfig拷贝到mall-pay-service工程的com.gupaoedu.vip.mall.pay.config包下。

修改mall-pay-service工程中的com.gupaoedu.vip.mall.pay.controller.WeixinPayController#pay方法,代码如下:

1606464365225

代码如下:

@Autowired
private Signature signature;

/*****
 * 预下单
 * ciptext:支付信息加密字符串  AES加密,包含验签
 * @return
 * @throws Exception
 */
@GetMapping(value = "/pay")
public RespResult<Map> pay(@RequestParam(value = "ciptext")String ciphertext) throws Exception {
    //数据解析,并验签校验
    Map<String, String> map = signature.security(ciphertext);
    //1分钱测试
    if(map!=null){
        Map<String, String> resultMap = weixinPayService.preOrder(map);
        resultMap.put("orderNumber",map.get("out_trade_no"));
        resultMap.put("money",map.get("total_fee"));
        return RespResult.ok(resultMap);
    }
    return RespResult.error("支付系统繁忙,请稍后再试!");
}

我们打开参考资料中的下单页面进行测试,效果如下:

1606465246909

2.5 支付结果处理

2.5.1 支付通知接收

1606465441194

​ 在创建预支付订单的时候,有一个参数notify_url,如上图,该地址是用户支付后,用于接收支付结果的地址,我们可以在创建预支付订单的时候把该地址写成本地可以接收的地址,但由于本地地址是局域网地址,我们需要借助第三方工具实现内网穿透,可以使用花生壳实现内网穿透。

2.5.1.1 花生壳使用(自学)

下载花生壳客户端,并按照下图操作:

1606465693214

登录花生壳个人中心,打开网址https://console.hsk.oray.com/forward,并点击增加映射:

1606465791459

映射配置如下:

1606466127939

如下图,外网访问http://2cw4969042.wicp.vip:25082的时候会访问本地的8090端口服务。

1606466176748

2.5.1.2 支付通知接收

1)通知地址变更

修改mall-order-servicecom.gupaoedu.vip.mall.order.pay.WeixinPayParam#weixinParam方法的notify_url参数,代码如下:

1606466491261

上图地址:http://2cw4969042.wicp.vip:25082/wx/result

2)通知结果处理

修改mall-pay-servicecom.gupaoedu.vip.mall.pay.controller.WeixinPayController#payLog方法,用于接收支付结果通知,API地址:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_7&index=8,代码如下:

/***
 * 记录支付结果
 * 执行事务消息发送
 */
@RequestMapping(value = "/result")
public String payLog(HttpServletRequest request) throws Exception{
    //获取支付结果
    ServletInputStream is = request.getInputStream();
    //接收存储网络输入流(微信服务器返回的支付状态数据)
    ByteArrayOutputStream os = new ByteArrayOutputStream();

    //缓冲区定义
    byte[] buffer = new byte[1024];
    int len = 0;
    //循环读取输入流,并写入到os中
    while ((len=is.read(buffer))!=-1){
        os.write(buffer,0,len);
    }

    //关闭资源
    os.close();
    is.close();

    //将支付结果转成xml的字符串
    String xmlResult = new String(os.toByteArray(),"utf-8");
    //将xmlResult转成Map
    Map<String, String> responseMap = WXPayUtil.xmlToMap(xmlResult);

    //记录日志
    int status = 7;//支付失败
    if(responseMap.get("return_code").equals(WXPayConstants.SUCCESS) && responseMap.get("result_code").equals(WXPayConstants.SUCCESS)){
        status=2;//已支付
    }
    PayLog payLog = new PayLog(responseMap.get("out_trade_no"),status,JSON.toJSONString(responseMap),responseMap.get("out_trade_no"),new Date());
    Message message = MessageBuilder.withPayload(JSON.toJSONString(payLog)).build();
    rocketMQTemplate.sendMessageInTransaction("rocket","log",message,null);

    //返回结果
    Map<String,String> resultMap = new HashMap<String,String>();
    resultMap.put("return_code","SUCCESS");
    resultMap.put("return_msg","OK");
    return WXPayUtil.mapToXml(resultMap);
}

支付后,测试效果如下:

1606468864344

2.5.2 订单状态变更

上面已经实现了订单支付结果的接收,并且将消息发到了RocketMQ,在mall-order-service中也监听到了结果,我们可以在mall-order-service中根据支付结果修改订单状态。

1)Service

接口:修改mall-order-servicecom.gupaoedu.vip.mall.order.service.OrderService添加修改订单状态方法:

/****
 * 支付后,修改订单状态
 */
int updateAfterPayStatus(String id);

实现类:修改mall-order-servicecom.gupaoedu.vip.mall.order.service.impl.OrderServiceImpl添加修改订单状态方法:

/***
 * 支付后,修改订单状态
 * @param id
 * @return
 */
@Override
public int updateAfterPayStatus(String id) {
    Order order = new Order();
    order.setId(id);
    order.setPayStatus(1);  //已支付
    order.setOrderStatus(1); //待发货

    QueryWrapper<Order> queryWrapper = new QueryWrapper<Order>();
    queryWrapper.eq("id",id);
    queryWrapper.eq("order_status",0);  //未完成
    queryWrapper.eq("pay_status",0);    //未支付
    return orderMapper.update(order,queryWrapper);
}

2)监听调用

修改com.gupaoedu.vip.mall.order.mq.OrderResultListenerprepareStart方法,实现监听,代码如下:

注入OrderService:

@Autowired
private OrderService orderService;

调用修改方法:

1606469539922

经过测试,可以实现订单状态自动更新,效果如下:

1606470185043

2.5.3 状态查询

支付完成后,我们应该跳转到支付成功页面,我们需要为支付状态提供一个查询方法,而且后面也有可能用到该方法,我们可以先查询本地日志,本地日志如果没有数据,就查询微信支付接口,并将日志存入到日志中。

查询支付状态接口地址:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_2

日志表如下:

CREATE TABLE `pay_log` (
  `id` varchar(60) NOT NULL COMMENT '支付唯一标识符',
  `status` int(1) NOT NULL COMMENT '状态:1未支付(NOTPAY),2支付成功(SUCCESS),3转入退款(REFUND),4已关闭(CLOSED),5 已撤销(REVOKED) ,6 用户支付中(USERPAYING),7 支付失败(PAYERROR),8 系统错误(SYSTEMERROR),9 已经关闭(ORDERCLOSED),10 签名错误(SIGNERROR),11 请使用POST方法(REQUIRE_POST_METHOD),12 操作错误(ERROR)',
  `content` varchar(2000) DEFAULT NULL COMMENT '支付凭证信息',
  `pay_id` varchar(60) DEFAULT NULL COMMENT '查询唯一标识符',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间、修改时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

1)Service

接口:修改mall-pay-servicecom.gupaoedu.vip.mall.pay.service.WeixinPayService接口,添加查询支付结果方法,代码如下:

/***
 * 支付结果查询
 * @param outno
 * @return
 */
PayLog result(String outno) throws Exception;

实现类:修改com.gupaoedu.vip.mall.pay.service.impl.WeixinPayServiceImpl添加结果查询方法,代码如下:

/***
 * 支付结果查询
 * @param outno
 * @return
 */
@Override
public PayLog result(String outno) throws Exception {
    //查询数据库
    QueryWrapper<PayLog> queryWrapper = new QueryWrapper<PayLog>();
    queryWrapper.eq("pay_id",outno);
    queryWrapper.orderByDesc("create_time");
    queryWrapper.last("limit 1");
    PayLog payLog = payLogMapper.selectOne(queryWrapper);
    if(payLog==null){
        //微信服务器查询
        payLog = new PayLog();
        Map<String,String> queryMap = new HashMap<String,String>();
        queryMap.put("out_trade_no",outno);
        Map<String, String> resultMap = wxPay.orderQuery(queryMap);

        //交易状态
        int state = tradeState(resultMap.get("trade_state"));
        payLog.setStatus(state);
        payLog.setPayId(outno);
        //支付结果(日志记录时间)
        payLog.setCreateTime(new Date());
        payLog.setId(IdWorker.getIdStr());
        payLog.setContent(JSON.toJSONString(resultMap));

        //状态不可逆转:已支付、转入退款、已关闭、已撤销、支付失败
        if(state==2 || state==3 || state==4 || state==5 || state==7){
            //添加到数据库
            payLogMapper.insert(payLog);
        }
    }
    return payLog;
}


/***
 * 支付状态
 * @param tradeState
 * @return
 */
public int tradeState(String tradeState){
    int state = 1;
    switch (tradeState){
        case "NOTPAY":  //未支付
            state = 1;
            break;
        case "SUCCESS":
            state = 2;  //已支付
            break;
        case "REFUND":
            state = 3;  //转入退款
            break;
        case "CLOSED":
            state = 4;  //已关闭
            break;
        case "REVOKED":
            state = 5;  //已撤销
            break;
        case "USERPAYING":
            state = 6;  //用户支付中
            break;
        case "PAYERROR":
            state = 7;  //支付失败
            break;
        default:
            state=1;
    }
    return state;
}

2)Controller

修改com.gupaoedu.vip.mall.pay.controller.WeixinPayController添加结果查询方法,代码如下:

/***
 * 支付状态查询
 */
@GetMapping(value = "/result/{outno}")
public RespResult<PayLog> query(@PathVariable(value = "outno")String outno) throws Exception {
    PayLog payLog = weixinPayService.result(outno);
    return RespResult.ok(payLog);
}

效果如下:

1606473236481

3 微信退款

1606474913870

当订单支付了,并且未发货状态下,是可以取消订单,并且此时取消订单需要执行退款操作,但是退款操作并不是那么容易去做的。

3.1 退款流程分析

1606529071900

退款流程比支付流程还要复杂,如上图:

1:用户发起取消订单操作,此时要执行退款申请。
2:订单服务接到取消订单后,检查是否符合取消订单情况,如果符合就执行退款申请,退款申请包括修改订单状态为申请退款,同时将退款申请操作发到MQ,让支付服务执行退款申请,但这里是异步执行。
3:此时提示用户申请成功,等待商家审核。
4:支付服务从MQ读取退款信息。
5:读取到退款信息后,向微信服务器发起退款申请操作。
6:微信服务器异步把退款申请结果发送到支付服务,此时并未退款,只是能否退款的结果。
7:支付服务把退款申请结果推送到MQ。
8:订单服务读取退款申请结果。
9:订单服务根据退款申请结果更新订单状态为申请退款成功,等待退款。
10:微信服务器会将退款结果推送到支付服务器。
11:支付服务器将退款结果推送到MQ。
12:订单服务读取推送的退款结果。
13:订单服务器根据退款结果更新订单状态为已退款。

3.2 取消订单操作

取消订单操作需要先更改订单状态,同时向MQ发送消息,这里需要保障事务同步,我们可以采用RocketMQ的事务消息实现。

3.2.1 退款记录操作

退款需要做退款记录,订单退款记录表如下:

CREATE TABLE `order_refund` (
  `id` varchar(60) NOT NULL,
  `order_no` varchar(60) NOT NULL COMMENT '退款订单',
  `refund_type` int(1) NOT NULL COMMENT '退款类型:0 整个订单退款,1:指定订单明细退款',
  `order_sku_id` varchar(60) DEFAULT NULL COMMENT '退款订单明细,当refund_type=1的时候填写该ID值',
  `status` int(1) NOT NULL COMMENT '状态,0:申请退款,1:退款成功,2:退款失败',
  `username` varchar(50) NOT NULL,
  `create_time` datetime NOT NULL,
  `money` int(11) NOT NULL COMMENT '退款金额',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

1)Api

我们需要在mall-order-api中创建对应的实体类:com.gupaoedu.vip.mall.order.model.OrderRefund

@Data
@AllArgsConstructor
@NoArgsConstructor
//MyBatisPlus表映射注解
@TableName(value = "order_refund")
public class OrderRefund implements Serializable {

    @TableId(type = IdType.ASSIGN_ID)
    private String id;
    private String orderNo;
    private Integer refundType;
    private String orderSkuId;
    private String username;
    private Integer status;
    private Date createTime;
    private Integer money;
}

2)Dao

mall-order-service中添加com.gupaoedu.vip.mall.order.mapper.OrderRefundMapper

public interface OrderRefundMapper extends BaseMapper<OrderRefund> {
}

3.2.2 退款申请操作

由于我们要用到MQ,所以需要配置MQ。

1)配置RocketMQ

修改mall-order-servicebootstrap.yml,添加RocketMQ配置:

#RocketMQ配置
rocketmq:
  name-server: 192.168.100.130:9876
  producer:
    #订单消息生产者
    group: order-provider
    send-message-timeout: 300000
    compress-message-body-threshold: 4096
    max-message-size: 4194304
    retry-times-when-send-async-failed: 0
    retry-next-server: true
    retry-times-when-send-failed: 2

2)Service

修改com.gupaoedu.vip.mall.order.service.OrderService添加退款操作,代码如下:

/****
 * 申请退款(取消订单)
 * @return
 */
int refund(OrderRefund orderRefund);

修改com.gupaoedu.vip.mall.order.service.impl.OrderServiceImpl添加退款操作,代码如下:

/****
 * 申请退款(取消订单)
 * @return
 */
@Transactional
@Override
public int refund(OrderRefund orderRefund) {
    //退款申请记录
    int icount = orderRefundMapper.insert(orderRefund);

    //订单状态变更
    Order order = new Order();
    order.setOrderStatus(4);            //申请退款
    //条件
    QueryWrapper<Order> queryWrapper = new QueryWrapper<Order>();
    queryWrapper.eq("id",orderRefund.getOrderNo());
    queryWrapper.eq("username",orderRefund.getUsername());
    //原来是已支付待发货状态
    queryWrapper.eq("order_status",1);  //待发货
    queryWrapper.eq("pay_status",1);    //已支付
    int mcount = orderMapper.update(order,queryWrapper);
    return mcount;
}

3)事务消息监听

mall-order-service中创建com.gupaoedu.vip.mall.order.mq.RefundTransactionListenerImpl用于向RocketMQ发送事务消息,同时更新订单状态,并记录退款订单信息,代码如下:

@Component
@RocketMQTransactionListener(txProducerGroup = "refundtx")
public class RefundTransactionListenerImpl implements RocketMQLocalTransactionListener {

    @Autowired
    private OrderService orderService;

    /***
     * 发送prepare消息成功后回调该方法用于执行本地事务
     * @param message:回传的消息,利用transactionId即可获取到该消息的唯一Id
     * @param o:调用send方法时传递的参数,当send时候若有额外的参数可以传递到send方法中,这里能获取到
     * @return
     */
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
        try {
            //================本地事务操作开始=====================================
            //修改本地状态
            int count = orderService.refund((OrderRefund) o);

            //如果申请退款失败,则回滚half消息
            if(count<=0){
                return RocketMQLocalTransactionState.ROLLBACK;
            }
            //================本地事务操作结束=====================================
        } catch (Exception e) {
            //异常,消息回滚
            e.printStackTrace();
            return RocketMQLocalTransactionState.ROLLBACK;
        }
        return RocketMQLocalTransactionState.UNKNOWN;
    }

    /***
     * 消息回查
     * @param message
     * @return
     */
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
        return RocketMQLocalTransactionState.COMMIT;
    }
}

4)数据验签

退款操作我们也防止数据泄露,也做验签校验,修改com.gupaoedu.vip.mall.order.pay.WeixinPayParam添加退款参数封装,并做验签操作,代码如下:

/****
 * 微信退款参数封装
 * 对参数进行签名
 * 对整体参数进行加密
 * @return
 */
public String weixinRefundParam(Order order,String outrefundno) throws Exception {
    //定义Map封装参数
    Map<String,String> dataMap = new HashMap<String,String>();
    dataMap.put("out_trade_no", order.getId());
    dataMap.put("out_refund_no", outrefundno);
    //dataMap.put("total_fee", String.valueOf(order.getMoneys()));
    dataMap.put("total_fee", "1"); //1分钱测试
    //退款金额
    //dataMap.put("refund_fee", String.valueOf(order.getMoneys()));
    dataMap.put("refund_fee", "1");//1分钱测试
    dataMap.put("notify_url", "http://2cw4969042.wicp.vip:25082/wx/refund/result");
    //生成签名,并且参数加密
    return signature.security(dataMap);
}

5)事务消息发送

修改mall-order-servicecom.gupaoedu.vip.mall.order.controller.OrderController,添加取消订单退款申请方法,代码如下:

@Autowired
private RocketMQTemplate rocketMQTemplate;

/***
 * 取消订单
 */
@PutMapping(value = "/refund/{id}")
public RespResult refund(@PathVariable(value = "id")String id,HttpServletRequest request) throws Exception {
    String userName = "gp";

    //查询商品信息
    Order order = orderService.getById(id);

    //已支付,待发货,才允许取消订单
    if(order.getOrderStatus().intValue()==1 && order.getPayStatus().intValue()==1){
        //退款记录
        OrderRefund orderRefund = new OrderRefund(
                IdWorker.getIdStr(),
                order.getId(),
                0,//0 整个订单退款,1 单个明细退款
                null,
                userName,
                0,//状态,0:申请退款,1:退款成功,2:退款失败
                new Date(),
                order.getMoneys()   //退款金额
        );

        //发送事务消息[退款加密信息]
        Message message = MessageBuilder.withPayload(weixinPayParam.weixinRefundParam(order,orderRefund.getId())).build();
        TransactionSendResult transactionSendResult = rocketMQTemplate.sendMessageInTransaction("refundtx", "refund", message, orderRefund);
        if(transactionSendResult.getSendStatus()== SendStatus.SEND_OK){
            return RespResult.error("申请退款成功,等待退款!");
        }
        return RespResult.error("不符合取消订单条件,无法退货!");
    }
    return RespResult.error("订单已发货,或无法退货!");
}

3.2.3 退款申请执行

在支付服务中监听消息,并调用微信支付服务的申请退款接口实现退款操作。

1)Service

接口:修改com.gupaoedu.vip.mall.pay.service.WeixinPayService添加退款方法,代码如下:

/***
 * 退款
 * @param map
 * @return
 */
Map<String, String>  refund(Map<String, String> map) throws Exception;

实现类:修改com.gupaoedu.vip.mall.pay.service.impl.WeixinPayServiceImpl添加退款实现,代码如下:

/***
 * 退款申请
 * @param map : 包含了out_trade_no
 * @return
 */
@Override
public Map<String, String>  refund(Map<String, String> map) throws Exception {
    //退款申请
    Map<String, String> resultMap = wxPay.refund(map);
    return resultMap;
}

2)退款申请监听

mall-pay-service中创建com.gupaoedu.vip.mall.pay.mq.RefundResultListener,并实现对退款申请的监听操作,监听到数据后,执行退款接口调用,代码如下:

@Component
@RocketMQMessageListener(topic = "refund", consumerGroup = "orderrefund-group")
public class RefundResultListener implements RocketMQListener,RocketMQPushConsumerLifecycleListener {

    @Autowired
    private WeixinPayService weixinPayService;

    @Autowired
    private Signature signature;

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /***
     * 监听消息
     * 实现RocketMQPushConsumerLifecycleListener监听器之后,此方法不调用
     * @param message
     */
    @Override
    public void onMessage(Object message) {
    }

    /***
     * 消息监听:退款事务监听
     * @param consumer
     */
    @Override
    public void prepareStart(DefaultMQPushConsumer consumer) {
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                try {
                    for (MessageExt msg : msgs) {
                        String result = new String(msg.getBody(),"UTF-8");

                        //数据解析,并验签校验
                        Map<String, String> map = signature.security(result);
                        if(map!=null){
                            //执行退款申请
                            Map<String, String>  resultMap = weixinPayService.refund(map);

                            System.out.println("退款申请resultMap:"+resultMap);
                        }
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                    return ConsumeConcurrentlyStatus.RECONSUME_LATER;
                }
                //消费状态
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
    }
}

退款申请resultMap:

{
  transaction_id=4200000724202011271996336311,
  nonce_str=f5cwyiRiGGoBOeTt,
  out_refund_no=1332312259728322562,
  sign=58FD3D8462D3A8893B855A27A1616CECBBF28B88B5482DE9E740B9206834E729,
  return_msg=OK,
  mch_id=1581433991,
  refund_id=50300706712020112804289880911,
  cash_fee=1,
  out_trade_no=1332311961987264513,
  coupon_refund_fee=0,
  refund_channel=,
  appid=wxb208ad76a7c389a9,
  refund_fee=1,
  total_fee=1,
  result_code=SUCCESS,
  coupon_refund_count=0,
  cash_refund_fee=1,
  return_code=SUCCESS
}

3.3 退款申请状态通知(作业)

1606529071900

我们将上面退款申请结果发送到MQ,同时做一次退款申请记录,退款申请记录表如下:

CREATE TABLE `refund_log` (
  `id` varchar(60) NOT NULL,
  `order_no` varchar(60) NOT NULL COMMENT '订单号',
  `out_refund_no` varchar(60) NOT NULL COMMENT '退款订单号(order_refund的id)',
  `money` int(11) DEFAULT NULL COMMENT '退款金额',
  `create_time` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

1)Api

为上面表创建一下实体Bean,在mall-pay-api中创建com.gupaoedu.vip.mall.pay.model.RefundLog

@Data
@AllArgsConstructor
@NoArgsConstructor
//MyBatisPlus表映射注解
@TableName(value = "refund_log")
public class RefundLog implements Serializable {

    @TableId(type = IdType.ASSIGN_UUID)
    private String id;
    private String orderNo;
    private String outRefundNo;
    private Integer money;
    private Date createTime;
}

2)Dao

mall-pay-service中创建com.gupaoedu.vip.mall.pay.mapper.RefundLogMapper

public interface RefundLogMapper extends BaseMapper<RefundLog> {
}

3)退款申请状态通知

mall-pay-service添加监听器com.gupaoedu.vip.mall.pay.mq.RefundStatusTransactionListenerImpl

用于监听退款申请结果。

@Component
@RocketMQTransactionListener(txProducerGroup = "refundstatustx")
public class RefundStatusTransactionListenerImpl implements RocketMQLocalTransactionListener {

    @Autowired
    private RefundLogMapper refundLogMapper;

    /***
     * 发送prepare消息成功后回调该方法用于执行本地事务
     * @param message:回传的消息,利用transactionId即可获取到该消息的唯一Id
     * @param o:调用send方法时传递的参数,当send时候若有额外的参数可以传递到send方法中,这里能获取到
     * @return
     */
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
        try {
            //================本地事务操作开始=====================================
            //将o转成Map
            Map<String,String> resultMap = (Map<String, String>) o;
            //添加退款日志记录
            RefundLog refundLog = new RefundLog(
                    IdWorker.getIdStr(),
                    resultMap.get("out_trade_no"),  //原订单号
                    resultMap.get("out_trade_no"),  //退款订单号(order_refund的id)
                    Integer.valueOf(resultMap.get("refund_fee")),   //退款金额
                    new Date()
            );
            int count = refundLogMapper.insert(refundLog);
            //================本地事务操作结束=====================================
        } catch (Exception e) {
            //异常,消息回滚
            e.printStackTrace();
            return RocketMQLocalTransactionState.ROLLBACK;
        }
        return RocketMQLocalTransactionState.UNKNOWN;
    }

    /***
     * 消息回查
     * @param message
     * @return
     */
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
        return RocketMQLocalTransactionState.COMMIT;
    }
}

4)事务消息发送

修改com.gupaoedu.vip.mall.pay.mq.RefundResultListener, 将退款申请结果推送到MQ,代码如下:

1606530007044

上图代码如下:

if(map!=null){
    //执行退款申请
    Map<String, String>  resultMap = weixinPayService.refund(map);
    //执行事务通知
    Message message = MessageBuilder.withPayload(resultMap).build();
    rocketMQTemplate.sendMessageInTransaction("refundstatustx",
                                              "refundstatus",
                                              message,
                                              resultMap);
}

3.4 订单退款申请状态更新(作业)

1606529071900

我们需要先监听到消息,再根据退款申请状态结果做出对应判断,如果退款申请通过了,需要更新订单表order_info的状态order_status改为5(退款申请成功),并且将退款订单记录表order_refund的状态status变更为2(退款申请成功,等待微信退款),如果退款申请失败了,此时需要走人工渠道,只需要更新退款记录状态,而订单状态不能更新,由工作人员更新。

3.4.1 退款申请状态处理

Service-退款申请成功:

接口:修改com.gupaoedu.vip.mall.order.service.OrderService添加退款申请成功修改订单记录状态以及订单状态的方法,代码如下:

/****
 * 退款申请成功
 * @param out_trade_no:订单号
 * @param out_refund_no:退款记录订单号
 */
void updateRefundStatus(String out_trade_no,String out_refund_no);

实现类:修改com.gupaoedu.vip.mall.order.service.impl.OrderServiceImpl添加接口实现方法,代码如下:

/***
 * 退款申请成功
 * @param out_trade_no:订单号
 * @param out_refund_no:退款记录订单号
 */
@Override
public void updateRefundStatus(String out_trade_no,String out_refund_no) {
    //订单状态更新
    Order order = new Order();
    order.setId(out_trade_no); //ID
    order.setOrderStatus(5); //退款申请成功
    orderMapper.updateById(order);

    //修改退款记录状态
    OrderRefund orderRefund = new OrderRefund();
    orderRefund.setId(out_refund_no);
    orderRefund.setStatus(2);   //退款申请成功,等待微信退款
    orderRefundMapper.updateById(orderRefund);
}

Service-退款申请失败:

接口:修改com.gupaoedu.vip.mall.order.service.OrderService添加退款申请失败修改订单记录状态的方法,代码如下:

/***
 * 退款申请失败,修改退款记录状态
 * @param out_refund_no
 */
void updateRefundFailStatus(String out_refund_no);

实现类:修改com.gupaoedu.vip.mall.order.service.impl.OrderServiceImpl添加接口实现方法,代码如下:

/***
 * 退款申请失败,修改退款记录状态
 * @param out_refund_no
 */
@Override
public void updateRefundFailStatus(String out_refund_no) {
    OrderRefund orderRefund = new OrderRefund();
    orderRefund.setId(out_refund_no);
    orderRefund.setStatus(1);   //退款申请失败(微信自动退款失败)
    orderRefundMapper.updateById(orderRefund);
}

3.4.2 退款申请结果监听

mall-order-service创建com.gupaoedu.vip.mall.order.mq.RefundStatusResultListener,用于监听退款申请状态信息,并执行订单状态以及订单退款记录状态更新,代码如下:

@Component
@RocketMQMessageListener(topic = "refundstatus", consumerGroup = "refundstatus-group")
public class RefundStatusResultListener implements RocketMQListener,RocketMQPushConsumerLifecycleListener {

    @Autowired
    private OrderService orderService;

    /***
     * 监听消息
     * 实现RocketMQPushConsumerLifecycleListener监听器之后,此方法不调用
     * @param message
     */
    @Override
    public void onMessage(Object message) {
    }

    /***
     * 消息监听
     * @param consumer
     */
    @Override
    public void prepareStart(DefaultMQPushConsumer consumer) {
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                try {
                    for (MessageExt msg : msgs) {
                        String result = new String(msg.getBody(),"UTF-8");
                        //获取payId,修改订单状态
                        Map<String,String> refundStatusMap = JSON.parseObject(result,Map.class);

                        //退款申请成功
                        String out_trade_no = refundStatusMap.get("out_trade_no"); //订单号
                        String out_refund_no = refundStatusMap.get("out_refund_no"); //订单号
                        if(refundStatusMap.get("return_code").equals("SUCCESS") && refundStatusMap.get("result_code").equals("SUCCESS")){
                            orderService.updateRefundStatus(out_trade_no,out_refund_no);
                        }else{
                            //退款失败,人工处理
                            orderService.updateRefundFailStatus(out_refund_no);
                        }
                    }
                } catch (UnsupportedEncodingException e) {
                    e.printStackTrace();
                }
                //消费状态
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
    }
}

3.5 退款结果处理

1606529071900

执行退款申请虽然状态通过了,但并不表示退款成功了,退款结果微信支付服务器会通过最初发起退款申请的时候留下的notify_url地址通知我们,所以我们可以编写一个方法接收退款结果,并将结果发送到订单服务,订单服务根据退款结果更新订单状态。

3.5.1 退款结果接收

退款申请的地址http://2cw4969042.wicp.vip:25082/wx/refund/result,我们在mall-pay-service中创建接收方法,修改com.gupaoedu.vip.mall.pay.controller.WeixinPayController添加接收方法,代码如下:

/***
 * 退款通知结果
 */
@RequestMapping(value = "/refund/result")
public String refundResult(HttpServletRequest request) throws Exception{
    System.out.println("*****************************退款通知*********************************");
    //获取结果
    ServletInputStream is = request.getInputStream();
    //接收存储网络输入流(微信服务器返回的支付状态数据)
    ByteArrayOutputStream os = new ByteArrayOutputStream();

    //缓冲区定义
    byte[] buffer = new byte[1024];
    int len = 0;
    //循环读取输入流,并写入到os中
    while ((len=is.read(buffer))!=-1){
        os.write(buffer,0,len);
    }

    //关闭资源
    os.close();
    is.close();

    //将结果转成xml的字符串
    String xmlResult = new String(os.toByteArray(),"utf-8");
    //将xmlResult转成Map
    Map<String, String> responseMap = WXPayUtil.xmlToMap(xmlResult);

    //发送MQ消息,普通消息,非事务消息
    Message message = MessageBuilder.withPayload(JSON.toJSONString(responseMap)).build();
    rocketMQTemplate.send("lastrefundresult",message);

    //返回结果
    Map<String,String> resultMap = new HashMap<String,String>();
    resultMap.put("return_code","SUCCESS");
    resultMap.put("return_msg","OK");
    return WXPayUtil.mapToXml(resultMap);
}

返回数据如下:

1606555471399

3.5.2 退款结果解密

1607913061650

微信退款中结果是加密处理了,我们需要获取退款结果并进行解密,但需要对商户秘钥做MD5加密,并转成小写。代码如下:

//获取退款信息(加密了-AES)
String reqinfo = responseMap.get("req_info");
String key = MD5.md5(skey);
byte[] decode = AESUtil.encryptAndDecrypt(Base64Util.decode(reqinfo), key, 2);
System.out.println("退款解密后的数据:"+new String(decode, "UTF-8"));

输出结果如下:

退款解密后的数据:<root>
<out_refund_no><![CDATA[1338309530114633729]]></out_refund_no>
<out_trade_no><![CDATA[1338284381151772673]]></out_trade_no>
<refund_account><![CDATA[REFUND_SOURCE_RECHARGE_FUNDS]]></refund_account>
<refund_fee><![CDATA[1]]></refund_fee>
<refund_id><![CDATA[50300406652020121404689603101]]></refund_id>
<refund_recv_accout><![CDATA[支付用户零钱]]></refund_recv_accout>
<refund_request_source><![CDATA[API]]></refund_request_source>
<refund_status><![CDATA[SUCCESS]]></refund_status>
<settlement_refund_fee><![CDATA[1]]></settlement_refund_fee>
<settlement_total_fee><![CDATA[1]]></settlement_total_fee>
<success_time><![CDATA[2020-12-14 10:27:18]]></success_time>
<total_fee><![CDATA[1]]></total_fee>
<transaction_id><![CDATA[4200000805202012140051202172]]></transaction_id>
</root>

我们可以把上述xml数据转成Map,再根据退款状态更新订单即可。

上次编辑于:
贡献者: soulballad