微信支付实战

学习时间:2022年5月20日

学习来源:尚硅谷

1 微信支付介绍和接入指引

1.1 微信支付产品介绍

image-20220617150016875

  • 付款码支付:用户展示微信钱包内的“付款码”给商家,商家扫描后直接完成支付,适用于线下面对面收银的场景。
  • JSAPI支付:
    • 线下场所:商户展示一个支付二维码,用户使用微信扫描二维码后,输入需要支付的金额,完成支付。
    • 公众号场景:用户在微信内进入商家公众号,打开某个页面,选择某个产品,完成支付。
    • PC网站场景:在网站中展示二维码,用户使用微信扫描二维码,输入需要支付的金额,完成支付。
    • 特点:用户在客户端输入支付金额
  • 小程序支付:在微信小程序平台内实现支付的功能
  • Native支付:Native支付是指商户展示支付二维码,用户再用微信“扫一扫”完成支付的模式。这种方式适用于PC网站。
    • 特点:商家预先指定支付金额
  • APP支付:商户通过在移动端独立的APP应用程序中集成微信支付模块,完成支付。
  • 刷脸支付:用户在刷脸设备前通过摄像头刷脸、识别身份后进行的一种支付方式。

1.2 接入指引

  • 获取商户号
  • 获取APPID
  • 获取API秘钥:APIv2版本的接口需要此秘钥
  • 获取APIv3秘钥:APIv3版本的接口需要此秘钥
  • 申请商户API证书:APIv3版本的所有接口都需要;APIv2版本的高级接口需要(如:退款、企业红包、企业付款等)
  • 获取微信平台证书:可以预先下载,也可以通过编程的方式获取。后面的课程中,我们会通过编程的方式来获取。
  • 注意:以上所有API秘钥和证书需妥善保管防止泄露

2 支付安全(证书/秘钥/签名)

2.1 信息安全的基础 - 机密性

  • 明文:加密前的消息叫“明文”(plain text)
  • 密文:加密后的文本叫“密文”(cipher text)
  • 密钥:只有掌握特殊“钥匙”的人,才能对加密的文本进行解密,这里的“钥匙”就叫做“密钥”(key)。“密钥”就是一个字符串,度量单位是“位”(bit),比如,密钥长度是 128,就是 16 字节的二进制串。
  • 加密:实现机密性最常用的手段是“加密”(encrypt)
    • 按照密钥的使用方式,加密可以分为两大类:对称加密和非对称加密。
  • 解密:使用密钥还原明文的过程叫“解密”(decrypt)
  • 加密算法:加密解密的操作过程就是“加密算法”
    • 所有的加密算法都是公开的,而算法使用的“密钥”则必须保密

2.2 对称加密和非对称加密

  • 对称加密
    • 特点:只使用一个密钥,密钥必须保密,常用的有 AES 算法
    • 优点:运算速度快
    • 缺点:秘钥需要信息交换的双方共享,一旦被窃取,消息会被破解,无法做到安全的密钥交换

image-20220617151237329

  • 非对称加密
    • 特点:使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,常用的有 RSA
    • 优点:黑客获取公钥无法破解密文,解决了密钥交换的问题
    • 缺点:运算速度非常慢

image-20220617151303068

  • 混合加密
    • 实际场景中把对称加密和非对称加密结合起来使用。

2.3 身份认证

image-20220617152303731

  • 公钥加密,私钥解密的作用是加密信息

image-20220617152317239

  • 私钥加密,公钥解密的作用是身份认证

image-20220617152405093

2.4 摘要算法

摘要算法就是我们常说的散列函数、哈希函数(Hash Function),它能够把任意长度的数据“压缩”成固定长度、而且独一无二的“摘要”字符串,就好像是给这段数据生成了一个数字“指纹”。

image-20220617152643663

  • 作用:保证信息的完整性,保证信息不会被篡改

  • 特性

    • 不可逆:只有算法,没有秘钥,只能加密,不能解密,从上图来说,就是:只能将z加密成h,而不能将h解密成z
    • 难题友好性:想要破解,只能暴力枚举
    • 发散性:只要对原文进行一点点改动,摘要就会发生剧烈变化
    • 抗碰撞性:原文不同,计算后的摘要也要不同
  • 常见摘要算法:MD5、SHA1、SHA2(SHA224、SHA256、SHA384)

图示

image-20220617153025719

  • Bob将原文的内容生成摘要,附在原文后面。
  • Pat接收到后,将原文按照相同算法生成摘要,与Bob附在原文后的摘要进行对比,若一致,则说明信息没有篡改。
  • 问题:当黑客将Bob发给Pat数据的途中将其截获,同时将原来的摘要取出,篡改原文后重新生成摘要附在原文后,则当Pat接收到后进行第二步的验证,是无法确定原文是否被篡改。

2.5 数字签名

数字签名是使用私钥对摘要加密生成签名,需要由公钥将签名解密后进行验证,一起实现身份认证和信息加密两个作用。

image-20220617153431970

该签名只能通过Bob的私钥将摘要加密才能生成,如果黑客不能获取Bob的私钥,是不能伪造签名的。

2.6 数字证书

数字证书解决“公钥的信任”问题,可以防止黑客伪造公钥。

不能直接分发公钥,公钥的分发必须使用数字证书,数字证书由CA颁发。

image-20220617153951085

上图,在实际场景中:Pat误以为自己是在和微信支付的服务器进行往来。

公钥的颁发

image-20220617154120396

image-20220617154256174

https中的数字证书

image-20220617154440042

3 案例项目的创建

3.1 创建项目

image-20220520172349588

注意:springboot版本:2.3.7.RELEASE,模板采用aliyun

  • 添加spring-boot-starter-web依赖
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

测试接口

  • 配置文件
1
2
3
4
5
server:
port: 8090
spring:
application:
name: payment-demo # 应用的名字
  • 新建controller.ProductTest.java
1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/api/product")
@CrossOrigin
public class ProductController {

@GetMapping("/test")
public String test() {
return "hello";
}

}

测试:http://localhost:8090/api/product/test

3.2 引入Swagger

  • 添加依赖
1
2
3
4
5
6
7
8
9
10
11
12
<!-- swagger -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.7.0</version>
</dependency>

<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.7.0</version>
</dependency>
  • 新建config.Swagger2config.java配置类
1
2
3
4
5
6
7
8
9
10
11
@Configuration
@EnableSwagger2
public class Swagger2Config {

@Bean
public Docket docket() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(new ApiInfoBuilder().title("微信支付案例接口文档").build());
}

}
  • 修改测试接口:controller中可以添加常用注解
1
2
3
4
5
6
7
8
9
10
11
12
@Api(tags = "商品管理") // 用在类上
@RestController
@RequestMapping("/api/product")
@CrossOrigin
public class ProductController {

@ApiOperation("测试接口") //用在方法上
@GetMapping("/test")
public String test() {
return "hello";
}
}

image-20220531194601029

3.3 定义统一结果

作用:定义统一响应结果,为前端返回标准格式的数据。

  • 引入lombok,简化实体类的开发
1
2
3
4
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
  • 创建统一结果类:vo.R.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Data // 生成get和set方法
public class R {
// 响应码
private Integer code;
// 响应消息
private String message;
// 响应数据
private Map<String, Object> data = new HashMap<>();

public static R ok() {
R r = new R();
r.setCode(0);
r.setMessage("成功");
return r;
}

public static R error() {
R r = new R();
r.setCode(-1);
r.setMessage("失败");
return r;
}

public R data(String key, Object value) {
this.data.put(key, value);
return this;
}
}
  • 修改测试类,返回统一结果
1
2
3
4
5
6
7
8
9
10
11
12
13
@Api(tags = "商品管理")
@RestController
@RequestMapping("/api/product")
@CrossOrigin
public class ProductController {

@ApiOperation("测试接口")
@GetMapping("/test")
public R test() {
// 链式操作,因为data方法返回的也是一个R对象,因此可以连续调用R的data方法
return R.ok().data("message", "hello").data("now", new Date());
}
}
  • 修改配置文件,统一时间格式和时区
1
2
3
4
5
6
spring:
application:
name: payment-demo # 应用的名字
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8

测试结果

image-20220531200329417

3.4 创建数据库

一共有四张表:

image-20220617154813262

3.5 集成MyBatis-plus

  • 引入依赖
1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.0</version>
</dependency>
  • 配置数据库连接:在spring节点下
1
2
3
4
5
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/payment_demo?serverTimezone=GTM%2B8&characterEncoding=utf-8
username: root
password: 12345678

代码(略)

  • 定义实体类:BaseEntity是父类,其他类继承BaseEntity

image-20220531202636633

1
2
3
4
5
6
7
8
9
10
11
@Data
public class BaseEntity {

//定义主键策略:跟随数据库的主键自增
@TableId(value = "id", type = IdType.AUTO)
private String id; //主键

private Date createTime;//创建时间

private Date updateTime;//更新时间
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Data
@TableName("t_order_info")
public class OrderInfo extends BaseEntity{

private String title;//订单标题

private String orderNo;//商户订单编号

private Long userId;//用户id

private Long productId;//支付产品id

private Integer totalFee;//订单金额(分)

private String codeUrl;//订单二维码连接

private String orderStatus;//订单状态
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Data
@TableName("t_payment_info")
public class PaymentInfo extends BaseEntity{

private String orderNo;//商品订单编号

private String transactionId;//支付系统交易编号

private String paymentType;//支付类型

private String tradeType;//交易类型

private String tradeState;//交易状态

private Integer payerTotal;//支付金额(分)

private String content;//通知参数
}
1
2
3
4
5
6
7
8
@Data
@TableName("t_product")
public class Product extends BaseEntity{

private String title; //商品名称

private Integer price; //价格(分)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
@TableName("t_refund_info")
public class RefundInfo extends BaseEntity{

private String orderNo;//商品订单编号

private String refundNo;//退款单编号

private String refundId;//支付系统退款单号

private Integer totalFee;//原订单金额(分)

private Integer refund;//退款金额(分)

private String reason;//退款原因

private String refundStatus;//退款单状态

private String contentReturn;//申请退款返回参数

private String contentNotify;//退款结果通知参数
}
  • 定义持久层:定义Mapper接口继承 BaseMapper<>,定义xml配置文件

image-20220531202711338

  • 定义业务层:定义业务层接口继承 IService<>,定义业务层接口的实现类,并继承 ServiceImpl<,>

image-20220531202757507

  • 定义MyBatis-Plus的配置文件:在config包中创建配置文件 MybatisPlusConfifig
1
2
3
4
5
6
@Configuration
@MapperScan("com.hongyi.paymentdemo.mapper") //持久层扫描
@EnableTransactionManagement // 启用事务管理
public class MyBatisPlusConfig {

}
  • 新增商品列表方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Api(tags = "商品管理")
@RestController
@RequestMapping("/api/product")
@CrossOrigin
public class ProductController {

@Resource
private ProductService productService;

@GetMapping("/list")
public R list() {
List<Product> list = productService.list();
return R.ok().data("productList", list);
}
}

响应结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
"code": 0,
"message": "成功",
"data": {
"productList": [
{
"id": "1",
"createTime": "2022-05-31 20:07:49",
"updateTime": "2022-05-31 20:07:49",
"title": "Java课程",
"price": 1
},
{
"id": "2",
"createTime": "2022-05-31 20:07:49",
"updateTime": "2022-05-31 20:07:49",
"title": "大数据课程",
"price": 1
},
{
"id": "3",
"createTime": "2022-05-31 20:07:49",
"updateTime": "2022-05-31 20:07:49",
"title": "前端课程",
"price": 1
},
{
"id": "4",
"createTime": "2022-05-31 20:07:49",
"updateTime": "2022-05-31 20:07:49",
"title": "UI课程",
"price": 1
}
]
}
}
  • 添加持久层日志和xml文件位置的配置:
1
2
3
4
mybatis-plus:
configuration: #sql日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath:com/hongyi/paymentdemo/mapper/xml/*.xml
1
2
3
4
5
6
7
8
9
10
11
12
<build>
<!--项目打包时会将java目录中的*.xml文件也进行打包-->
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
<filtering>false</filtering>
</resource>
</resources>
</build>

3.6 前端项目创建

过程略。

整个项目的架构:

image-20220628205343926

4 Native支付API V3

4.1 引入支付参数

将资料文件夹中的 wxpay.properties 复制到resources目录中。

这个文件定义了之前我们准备的微信支付相关的参数,例如商户号、APPID、API秘钥等等。

以下敏感信息已经置空:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 微信支付相关参数 烤肉串模式,springboot可将其自动映射为java类中属性的驼峰风格
# 商户号
wxpay.mch-id=
# 商户API证书序列号
wxpay.mch-serial-no=
# 商户私钥文件
wxpay.private-key-path=
# APIv3密钥
wxpay.api-v3-key=
# APPID
wxpay.appid=
# 微信服务器地址
wxpay.domain=
# 接收结果通知地址
wxpay.notify-domain=
  • 配置类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
@PropertySource("classpath:wxpay.properties") //读取配置文件
@ConfigurationProperties(prefix="wxpay") //读取wxpay节点
@Data //使用set方法将wxpay节点中的值填充到当前类的属性中,按照中划线模式到驼峰的对应关系
public class WxPayConfig {

// 商户号
private String mchId;

// 商户API证书序列号
private String mchSerialNo;

// 商户私钥文件
private String privateKeyPath;

// APIv3密钥
private String apiV3Key;

// APPID
private String appid;

// 微信服务器地址
private String domain;

// 接收结果通知地址
private String notifyDomain;

}
  • 测试支付参数的获取

controller 包中创建 TestController

1
2
3
4
5
6
7
8
9
10
11
12
13
@Api(tags = "测试控制器")
@RestController
@RequestMapping("/api/test")
public class TestController {
@Resource
private WxPayConfig wxPayConfig;

@GetMapping
public R getWxPayConfig() {
String mchId = wxPayConfig.getMchId();
return R.ok().data("mchId", mchId);
}
}

测试结果:

image-20220617160133099

补充——自定义配置文件

将wxpay.properties纳入springboot的配置文件范围内:

  • 引入依赖:该依赖可以帮助我们生成自定义配置的元数据信息,让配置文件和Java代码之间的对应参数可以自动定位,方便开发。
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
  • 配置:让IDEA可以识别配置文件,将配置文件的图标展示成SpringBoot的图标,同时配置文件的内容可以高亮显示。
    • File -> Project Structure -> Modules -> 选择小叶子
    • 点击(+) 图标
    • 选中配置文件保存即可

image-20220617160341529

4.2 加载商户私钥

  • 复制商户私钥:将下载的私钥文件apiclient_key.pem复制到项目根目录下
  • 引入SDK依赖:
    • 文档
    • 我们可以使用官方提供的 SDK,帮助我们完成开发。实现了请求签名的生成和应答签名的验证
1
2
3
4
5
<dependency> 
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-apache-httpclient</artifactId>
<version>0.3.0</version>
</dependency>
  • 获取商户私钥:在WxPayConfig新增获取商户私钥的方法
1
2
3
4
5
6
7
8
9
10
11
12
/**
* 获取商户的私钥文件
* @param filename
* @return
*/
private PrivateKey getPrivateKey(String filename) {
try {
return PemUtil.loadPrivateKey(new FileInputStream(filename));
} catch (FileNotFoundException e) {
throw new RuntimeException("私钥文件不存在", e);
}
}
  • 测试:
    • 在 PaymentDemoApplicationTests 测试类中添加如下方法,测试私钥对象是否能够获取出来。(将前面的方法改成public的再进行测试)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@SpringBootTest
class PaymentDemoApplicationTests {

@Resource
private WxPayConfig wxPayConfig;

@Test
void testGetPrivateKey() {
String privateKeyPath = wxPayConfig.getPrivateKeyPath();
PrivateKey privateKey = wxPayConfig.getPrivateKey(privateKeyPath);
System.out.println(privateKey);
}

}

4.3 获取签名验证器和HttpClient

4.3.1 证书秘钥使用说明

image-20220628162759232

网址:https://pay.weixin.qq.com/wiki/doc/apiv3_partner/wechatpay/wechatpay3_0.shtml

4.3.2 获取签名验证器

https://github.com/wechatpay-apiv3/wechatpay-apache-httpclient (定时更新平台证书功能)它会定时下载和更新商户对应的微信支付平台证书 (默认下载间隔为UPDATE_INTERVAL_MINUTE)。

  • 平台证书:平台证书封装了微信的公钥,商户可以使用平台证书中的公钥进行验签。
  • 签名验证器:帮助我们进行验签工作,我们单独将它定义出来,方便后面的开发。

在WxPayConfig中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 获取签名验证器
* @return
*/
// 注入到容器中,方便直接获取
@Bean
public ScheduledUpdateCertificatesVerifier getVerifier() {
// 获取商户私钥
PrivateKey privateKey = getPrivateKey(privateKeyPath);
// 私钥签名
PrivateKeySigner privateKeySigner = new PrivateKeySigner(mchSerialNo, privateKey);
// 身份认证对象
WechatPay2Credentials wechatPay2Credentials = new WechatPay2Credentials(mchId, privateKeySigner);
// 使用定时更新的签名验证器,不需要传入证书
ScheduledUpdateCertificatesVerifier verifier = new ScheduledUpdateCertificatesVerifier(
wechatPay2Credentials,
apiV3Key.getBytes(StandardCharsets.UTF_8));
return verifier;
}

4.3.3 获取 HttpClient 对象

  • HttpClient 对象:是建立远程连接的基础,我们通过SDK创建这个对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 获取http请求对象
* @param verifier
* @return
*/
// 注入到容器中,方便直接获取
@Bean
public CloseableHttpClient getWxPayClient(ScheduledUpdateCertificatesVerifier verifier) {
// 获取商户私钥
PrivateKey privateKey = getPrivateKey(privateKeyPath);
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
.withMerchant(mchId, mchSerialNo, privateKey)
.withValidator(new WechatPay2Validator(verifier));
// 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签,并进行证书自动更新
CloseableHttpClient httpClient = builder.build();
return httpClient;
}

4.4 API字典和相关工具

https://pay.weixin.qq.com/wiki/doc/apiv3/open/pay/chapter2_7_3.shtml

指引文档——

项目要实现以下功能:

image-20220628165402311

接口规则

https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay-1.shtml

为了在保证支付安全的前提下,带给商户简单、一致且易用的开发体验,我们推出了全新的微信支付API v3。相较于之前的微信支付API,主要区别是:

  • 遵循统一的REST的设计风格
  • 使用JSON作为数据交互的格式,不再使用XML
  • 使用基于非对称密钥的SHA256-RSA的数字签名算法,不再使用MD5或HMAC-SHA256
  • 不再要求携带HTTPS客户端证书(仅需携带证书序列号)
  • 使用AES-256-GCM(对称加密算法),对回调中的关键信息进行加密保护

微信支付 APIv3 使用 JSON 作为消息体的数据交换格式。添加处理JSON的依赖:

1
2
3
4
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>

4.4.1 定义枚举

将资料文件夹中的 enums 目录复制到源码目录中。

为了开发方便,我们预先在项目中定义一些枚举。枚举中定义的内容包括接口地址,支付状态等信息。

代码示例

image-20220628165807856

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@AllArgsConstructor
@Getter
public enum OrderStatus {
/**
* 未支付
*/
NOTPAY("未支付"),


/**
* 支付成功
*/
SUCCESS("支付成功"),

/**
* 已关闭
*/
CLOSED("超时已关闭"),

/**
* 已取消
*/
CANCEL("用户已取消"),

/**
* 退款中
*/
REFUND_PROCESSING("退款中"),

/**
* 已退款
*/
REFUND_SUCCESS("已退款"),

/**
* 退款异常
*/
REFUND_ABNORMAL("退款异常");

/**
* 类型
*/
private final String type;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@AllArgsConstructor
@Getter
public enum PayType {
/**
* 微信
*/
WXPAY("微信"),


/**
* 支付宝
*/
ALIPAY("支付宝");

/**
* 类型
*/
private final String type;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 微信服务器的API后缀
@AllArgsConstructor
@Getter
public enum WxApiType {

/**
* Native下单
*/
NATIVE_PAY("/v3/pay/transactions/native"),

/**
* Native下单
*/
NATIVE_PAY_V2("/pay/unifiedorder"),

/**
* 查询订单
*/
ORDER_QUERY_BY_NO("/v3/pay/transactions/out-trade-no/%s"),

/**
* 关闭订单
*/
CLOSE_ORDER_BY_NO("/v3/pay/transactions/out-trade-no/%s/close"),

/**
* 申请退款
*/
DOMESTIC_REFUNDS("/v3/refund/domestic/refunds"),

/**
* 查询单笔退款
*/
DOMESTIC_REFUNDS_QUERY("/v3/refund/domestic/refunds/%s"),

/**
* 申请交易账单
*/
TRADE_BILLS("/v3/bill/tradebill"),

/**
* 申请资金账单
*/
FUND_FLOW_BILLS("/v3/bill/fundflowbill");


/**
* 类型
*/
private final String type;
}

其余略。

4.4.2 添加工具类

将资料文件夹中的 util 目录复制到源码目录中,我们将会使用这些辅助工具简化项目的开发。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class HttpUtils {

/**
* 将通知参数转化为字符串
* 用于处理回调通知
* @param request
* @return
*/
public static String readData(HttpServletRequest request) {
BufferedReader br = null;
try {
StringBuilder result = new StringBuilder();
br = request.getReader();
for (String line; (line = br.readLine()) != null; ) {
if (result.length() > 0) {
result.append("\n");
}
result.append(line);
}
return result.toString();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
if (br != null) {
try {
br.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 订单号工具类
* 生成订单号
* @author qy
* @since 1.0
*/
public class OrderNoUtils {

/**
* 获取订单编号
* @return
*/
public static String getOrderNo() {
return "ORDER_" + getNo();
}

/**
* 获取退款单编号
* @return
*/
public static String getRefundNo() {
return "REFUND_" + getNo();
}

/**
* 获取编号
* @return
*/
public static String getNo() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
String newDate = sdf.format(new Date());
String result = "";
Random random = new Random();
for (int i = 0; i < 3; i++) {
result += random.nextInt(10);
}
return newDate + result;
}

}

4.5 Native下单API

商户Native支付下单接口,微信后台系统返回链接参数code_url,商户后台系统将code_url值生成二维码图片,用户使用微信客户端扫码后发起支付。

4.5.1 Native支付流程

https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_4_4.shtml

image-20220628170510789

4.5.2 下单代码实现

① 说明

https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_4_1.shtml

商户端发起支付请求,微信端创建支付订单并生成支付二维码链接,微信端将支付二维码返回给商户端,商户端显示支付二维码,用户使用微信客户端扫码后发起支付。

请求参数

注意,封装请求体参数时,变量名务必保持一致。

参数名 变量 类型[长度限制] 必填 描述
应用ID appid string[1,32] body 由微信生成的应用ID,全局唯一。请求基础下单接口时请注意APPID的应用属性,例如公众号场景下,需使用应用属性为公众号的APPID 示例值:wxd678efh567hg6787
直连商户号 mchid string[1,32] body 直连商户的商户号,由微信支付生成并下发。 示例值:1230000109
商品描述 description string[1,127] body 商品描述 示例值:Image形象店-深圳腾大-QQ公仔
商户订单号 out_trade_no string[6,32] body 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一 示例值:1217752501201407033233368018
通知地址 notify_url string[1,256] body 通知URL必须为直接可访问的URL,不允许携带查询串,要求必须为https地址。 格式:URL 示例值:https://www.weixin.qq.com/wxpay/pay.php

嵌套类型:

image-20220628200032108

请求示例——json格式

1
2
3
4
5
6
7
8
9
10
11
{
"mchid": "1900006XXX",
"out_trade_no": "native12177525012014070332333",
"appid": "wxdace645e0bc2cXXX",
"description": "Image形象店-深圳腾大-QQ公仔",
"notify_url": "https://weixin.qq.com/",
"amount": {
"total": 1,
"currency": "CNY"
}
}

返回格式

1
2
3
{
"code_url": "weixin://wxpay/bizpayurl?pr=p4lpSuKzz"
}
② WxPayService和实现类
1
2
3
4
5
6
7
8
9
public interface WxPayService {
/**
* 创建订单,调用native支付接口
* @param productId 购买产品的id
* @return code_url和订单号
* @throws Exception
*/
Map<String, Object> nativePay(Long productId) throws Exception;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@Service
@Slf4j
public class WxPayServiceImpl implements WxPayService {

@Resource
private WxPayConfig wxPayConfig;

@Resource
private CloseableHttpClient httpClient;

/**
* 创建订单,调用native支付接口
* @param productId 购买产品的id
* @return code_url和订单号
* @throws Exception
*/
@Override
public Map<String, Object> nativePay(Long productId) throws Exception {
log.info("生成订单");
// 生成订单
OrderInfo orderInfo = new OrderInfo();
orderInfo.setTitle("test");
orderInfo.setOrderNo(OrderNoUtils.getOrderNo()); // 订单号
orderInfo.setProductId(productId);
orderInfo.setTotalFee(1); // 价格,单位为分
orderInfo.setOrderStatus(OrderStatus.NOTPAY.getType());
// TODO: 存入数据库

log.info("调用统一下单API");
// 调用统一下单API,构造Post请求
HttpPost httpPost = new HttpPost(wxPayConfig.getDomain().concat(WxApiType.NATIVE_PAY.getType()));

// 请求body参数
Gson gson = new Gson();
Map paramsMap = new HashMap<>();
paramsMap.put("appid", wxPayConfig.getAppId());
paramsMap.put("mchid", wxPayConfig.getMchId());
paramsMap.put("description", orderInfo.getTitle());
paramsMap.put("out_trade_no", orderInfo.getOrderNo());
paramsMap.put("notify_url", wxPayConfig.getNotifyDomain().concat(WxNotifyType.NATIVE_NOTIFY.getType()));
// 注意查阅文档得知,amount为嵌套结构
Map amountMap = new HashMap<>();
amountMap.put("total", orderInfo.getTotalFee());
amountMap.put("currency", "CNY");
paramsMap.put("amount", amountMap);
// 将Map对象转换为json
String jsonParams = gson.toJson(paramsMap);
log.info("请求参数: " + jsonParams);

// 将请求参数放入请求体中,设置请求和响应类型为json
StringEntity entity = new StringEntity(jsonParams,"utf-8");
entity.setContentType("application/json");
httpPost.setEntity(entity);
httpPost.setHeader("Accept", "application/json");

// 完成签名并执行请求
CloseableHttpResponse response = httpClient.execute(httpPost);

try {
// 获取响应体
String bodyAsString = EntityUtils.toString(response.getEntity());
// 获取响应状态码
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 200) { //处理成功
log.info("success,return body = " + bodyAsString);
} else if (statusCode == 204) { //处理成功,无返回Body
log.info("success");
} else {
log.info("failed,resp code = " + statusCode+ ",return body = " + bodyAsString);
throw new IOException("request failed");
}
// 响应结果
HashMap<String, String> resultMap = gson.fromJson(bodyAsString, HashMap.class);
// 二维码
String codeUrl = resultMap.get("code_url");
HashMap<String, Object> map = new HashMap<>();
map.put("codeUrl", codeUrl);
// 订单编号
map.put("orderNo", orderInfo.getOrderNo());
return map;
} finally {
response.close();
}
}
}
③ WxPayController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@CrossOrigin
@RestController
@RequestMapping("/api/wx-pay")
@Api(tags = "网站微信支付api")
@Slf4j
public class WxPayController {

@Resource // 这个是java规范的注解,Spring的为@Autowired
private WxPayService wxPayService;

@ApiOperation("调用统一下单API,生成支付二维码")
@PostMapping("/native/{productId}")
public R nativePay(@PathVariable Long productId) throws Exception {
log.info("发起支付请求");
// 返回支付二维码连接和订单号
Map<String, Object> map = wxPayService.nativePay(productId);
// 链式调用
return R.ok().setData(map);
}
}

注意:R.ok().setData()的返回值本为void:

image-20220628203641280

可在R类名上添加注解,使其支持链式调用:

1
2
3
4
5
@Data // 生成get和set方法
@Accessors(chain = true) // 链式调用
public class R {
// ...
}

image-20220628203747275

执行结果

打印信息:

1
2
3
4
5
发起支付请求
生成订单
调用统一下单API
请求参数: {"amount":{"total":1,"currency":"CNY"},"mchid":"1558950191","out_trade_no":"ORDER_20220628203154946","appid":"wx74862e0dfcf69954","description":"test","notify_url":"https://7d92-115-171-63-135.ngrok.io/api/wx-pay/native/notify"}
success,return body = {"code_url":"weixin://wxpay/bizpayurl?pr=imbupzpzz"}

前端展示:

image-20220628204018259

4.5.3 前端代码解析

① 商品列表展示

前端在页面刚刚加载时,调用后端接口/api/product/list

后端接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/api/product")
public class ProductController {

@Resource
private ProductService productService;

// 其余代码略
@GetMapping("/list")
public R list() {
List<Product> list = productService.list();
// 注意将数据封装在键为productList的值中
return R.ok().data("productList", list);
}
}

index.vue组件(关键内容)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<template>
<ul>
<li v-for="product in productList" :key="product.id">
<a :class="['orderBtn', {current:payOrder.productId === product.id}]"
@click="selectItem(product.id)"
href="javascript:void(0);" >
{{product.title}}
¥{{product.price / 100}}
</a>
</li>
</ul>
</template>

<script>
import productApi from '../api/product'

export default {

data () {
return {
productList: [], //商品列表
}
},

// 钩子函数
// 在页面加载时获取全部数据,注意这是常用方法
created () {
//获取商品列表
productApi.list().then(response => {
this.productList = response.data.productList
this.payOrder.productId = this.productList[0].id
})
},
}
</script>

created()中,调用productApi.list()方法发送ajax请求,获取数据。

productApi.js

1
2
3
4
5
6
7
8
9
10
11
12
// axios 发送ajax请求
import request from '@/utils/request'

export default{

list() {
return request({
url: '/api/product/list',
method: 'get'
})
}
}

其中request又是从工具方法@/utils/request导入的

request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import axios from 'axios'
import { Message } from 'element-ui'

// 创建axios实例
const service = axios.create({
baseURL: 'http://localhost:8090', // 后端api的 base_url
timeout: 10000 // 请求超时时间,如果后端在这个时间内没有返回给前端响应数据,则前端会报错并跳出警告窗口给用户
})

// request拦截器 略

// response 拦截器 略
)

export default service
② 二维码展示

展示

1
2
3
4
5
6
7
8
9
<el-dialog
:visible.sync="codeDialogVisible"
:show-close="false"
@close="closeDialog"
width="350px"
center>
<qriously :value="codeUrl" :size="300"/>
使用微信扫码支付
</el-dialog>

其中引入了第三方组件:vue-qriously,用于展示二维码。visible属性用于控制可见性,由codeDialogVisible决定,默认为false

js

当点击支付时,执行相应方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//确认支付
toPay(){
this.payBtnDisabled = true

//微信支付
if(this.payOrder.payType === 'wxpay'){
//调用统一下单接口
wxPayApi.nativePay(this.payOrder.productId).then(response => {
this.codeUrl = response.data.codeUrl
this.orderNo = response.data.orderNo
this.codeDialogVisible = true

// 启动定时器
this.timer = setInterval(() => {
this.queryOrderStatus()
}, 3000)
})
}
},
// ...

4.5.4 签名与验签原理

关键代码:

1
2
// 完成签名并执行请求
CloseableHttpResponse response = httpClient.execute(httpPost);
① 签名原理

签名生成流程:

https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay4_0.shtml

② 验签原理

签名验证流程:

https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay4_1.shtml

4.5.5 创建课程订单

① 保存订单

OrderInfoService中新建方法:

1
2
3
4
5
6
7
8
public interface OrderInfoService extends IService<OrderInfo> {
/**
* 根据商品id生成订单
* @param productId
* @return
*/
OrderInfo createOrderByProductById(Long productId);
}

实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Service
@Slf4j
public class OrderInfoServiceImpl extends ServiceImpl<OrderInfoMapper, OrderInfo> implements OrderInfoService {

@Resource
private ProductMapper productMapper;

@Resource
private OrderInfoMapper orderInfoMapper;

@Override
public OrderInfo createOrderByProductById(Long productId) {
// 获取商品信息
Product product = productMapper.selectById(productId);
// 生成订单
OrderInfo orderInfo = new OrderInfo();
orderInfo.setTitle(product.getTitle());
orderInfo.setOrderNo(OrderNoUtils.getOrderNo()); // 订单号
orderInfo.setProductId(productId);
orderInfo.setTotalFee(product.getPrice()); // 价格,单位为分
orderInfo.setOrderStatus(OrderStatus.NOTPAY.getType());
orderInfoMapper.insert(orderInfo);
return orderInfo;
}

}

WxPayServiceImpl修改为:省略了无关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
@Slf4j
public class WxPayServiceImpl implements WxPayService {

@Resource
private OrderInfoService orderInfoService;

@Override
public Map<String, Object> nativePay(Long productId) throws Exception {
log.info("生成订单");

// 生成订单
OrderInfo orderInfo = orderInfoService.createOrderByProductById(productId);
// ...
}

优化

优化目的:防止重复创建订单对象,防止数据库有冗余信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Service
@Slf4j
public class OrderInfoServiceImpl extends ServiceImpl<OrderInfoMapper, OrderInfo> implements OrderInfoService {

@Resource
private ProductMapper productMapper;

@Resource
private OrderInfoMapper orderInfoMapper;

@Override
public OrderInfo createOrderByProductById(Long productId) {
// 查找已存在但未支付的订单
OrderInfo orderInfo = this.getNoPayOrderByProductById(productId);
if (orderInfo != null) {
return orderInfo;
}
// 获取商品信息
Product product = productMapper.selectById(productId);
// 生成订单
orderInfo = new OrderInfo();
orderInfo.setTitle(product.getTitle());
orderInfo.setOrderNo(OrderNoUtils.getOrderNo()); // 订单号
orderInfo.setProductId(productId);
orderInfo.setTotalFee(product.getPrice()); // 价格,单位为分
orderInfo.setOrderStatus(OrderStatus.NOTPAY.getType());
orderInfoMapper.insert(orderInfo);
return orderInfo;
}

/**
* 根据商品id查询未支付的订单
* @param productId
* @return
*/
private OrderInfo getNoPayOrderByProductById(Long productId) {
QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("product_id", productId)
.eq("order_status", OrderStatus.NOTPAY.getType());
return orderInfoMapper.selectOne(queryWrapper);
}

}
② 缓存二维码

OrderInfoService接口

1
2
3
4
5
6
/**
* 根据订单编号更改二维码地址
* @param orderNo
* @param codeUrl
*/
void saveCodeUrl(String orderNo, String codeUrl);

实现:

1
2
3
4
5
6
7
8
@Override
public void saveCodeUrl(String orderNo, String codeUrl) {
QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("order_no", orderNo);
OrderInfo orderInfo = new OrderInfo();
orderInfo.setCodeUrl(codeUrl);
orderInfoMapper.update(orderInfo, queryWrapper);
}

修改WxPayServiceImpl 的 nativePay 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 创建订单,调用native支付接口
* @param productId 购买产品的id
* @return code_url和订单号
* @throws Exception
*/
@Override
public Map<String, Object> nativePay(Long productId) throws Exception {
log.info("生成订单");

// 生成订单
OrderInfo orderInfo = orderInfoService.createOrderByProductById(productId);
String codeUrl = orderInfo.getCodeUrl();
if (orderInfo != null && !StringUtils.isEmpty(codeUrl)) {
log.info("订单已存在,二维码已保存");
// 返回二维码
HashMap<String, Object> map = new HashMap<>();
map.put("codeUrl", codeUrl);
// 订单编号
map.put("orderNo", orderInfo.getOrderNo());
return map;
}

// 其他代码...

// 响应结果
HashMap<String, String> resultMap = gson.fromJson(bodyAsString, HashMap.class);
// 二维码
codeUrl = resultMap.get("code_url");
// 保存二维码
String orderNo = orderInfo.getOrderNo();
orderInfoService.saveCodeUrl(orderNo, codeUrl);
// 返回二维码
HashMap<String, Object> map = new HashMap<>();
map.put("codeUrl", codeUrl);
// 订单编号
map.put("orderNo", orderInfo.getOrderNo());
return map;
}

4.5.6 显示订单列表

需求:在我的订单页面按时间倒序显示订单列表。

① 新增OrderInfoService接口方法
1
2
3
4
5
/**
* 查询订单列表,并按时间倒序查询
* @return
*/
List<OrderInfo> listOrderByCreateTimeDesc();

实现:

1
2
3
4
5
6
@Override
public List<OrderInfo> listOrderByCreateTimeDesc() {
QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.orderByDesc("create_time");
return orderInfoMapper.selectList(queryWrapper);
}
② 创建OrderInfoController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Api(tags = "商品订单管理")
@RestController
@CrossOrigin
@RequestMapping("/api/order-info")
public class OrderInfoController {
@Resource
private OrderInfoService orderInfoService;

@GetMapping("/list")
public R list() {
List<OrderInfo> list = orderInfoService.listOrderByCreateTimeDesc();
return R.ok().data("list", list);
}
}

前端逻辑略

执行结果

image-20220629162751621

4.6 支付通知API

4.6.1 内网穿透配置

开发环境下通常处于内网或局域网(例如127.0.0.1),微信服务器要向内网地址发送通知,此时我们需要进行内网穿透。项目上线时不需要此步,而是填写公网服务器的ip地址。

  • 工具:ngrok(免费)

  • 形式:

    • 微信服务器(公网)发送通知,地址为ngrok的公网地址

    • ngrok建立隧道,将其映射为内网地址

设置 authToken

为本地计算机做授权配置:

1
ngrok authtoken 6aYc6Kp7kpxVr8pY88LkG_6x9o18yMY8BASrXiDFMeS

启动服务

指明协议和端口号(后端服务器的端口号,即接收微信服务器通知的端口号)

1
ngrok http 8090

image-20220629164655161

注意,每次重新启动后,获取的公网地址均不相同

将该地址填写入wxpay.properties

1
2
3
# 接收结果通知地址
# 注意:每次重新启动ngrok,都需要修改这个配置
wxpay.notify-domain=https://7d92-115-171-63-135.ngrok.io

测试

image-20220629164843013

4.6.2 接收通知和返回应答

① 接口说明

支付通知API:

https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_4_5.shtml

  • 请求方式:POST

  • 回调URL:该链接是通过基础下单接口中的请求参数“notify_url”来设置的,要求必须为https地址。请确保回调URL是外部可正常访问的,且不能携带后缀参数,否则可能导致商户无法接收到微信的回调通知信息。

通知报文

支付结果通知是以POST方法访问商户设置的通知url,通知的数据以JSON格式通过请求主体(BODY)传输。通知的数据包括了加密的支付结果详情。(注:由于涉及到回调加密和解密,商户必须先设置好apiv3秘钥后才能解密回调通知,apiv3秘钥设置文档指引详见APIv3秘钥设置指引

通知应答

接收成功:HTTP应答状态码需返回200或204,无需返回应答报文。

接收失败:HTTP应答状态码需返回5XX或4XX,同时需返回应答报文。格式如下:

1
2
3
4
{  
"code": "FAIL",
"message": "失败"
}
② 创建通知方法nativeNotify

WxPayController中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@PostMapping("/native/notify")
public String nativeNotify(HttpServletRequest request, HttpServletResponse response) {
Gson gson = new Gson();
// 应答对象
Map<String, String> map = new HashMap<>();
// 处理通知参数
String body = HttpUtils.readData(request);
Map<String, Object> bodyMap = gson.fromJson(body, HashMap.class);
log.info("支付通知的id ====> {}", bodyMap.get("id"));
log.info("支付通知完整的数据 ====> {}", body);
Object id = bodyMap.get("id");
// TODO: 签名的验证

// TODO: 处理订单

// 成功应答:成功应答必须为200或204,否则就是失败应答
response.setStatus(200);
map.put("code", "SUCCESS");
map.put("message", "成功");
return gson.toJson(map);
}

扫码并支付后,控制台输出:

1
2
3
支付通知的id ====> 7e17d1d8-754c-5eae-a299-ef5e2b515229
支付通知完整的数据 ====> {"id":"7e17d1d8-754c-5eae-a299-ef5e2b515229","create_time":"2022-06-29T17:23:31+08:00","resource_type":"encrypt-resource","event_type":"TRANSACTION.SUCCESS","summary":"支付成功","resource":{"original_type":"transaction","algorithm":"AEAD_AES_256_GCM","ciphertext":"t4lhXQyirANY5yr/Z3JuQHGNPAqCgKy9f6HET131gShJH52RyQR0TqCmUON6jRPGU29/nQjo383+4+SOEPzyutVZQRpOFX0te4egAokTmklTXz+C6l1n56XN/KI2iVPhShvZn32zejyYBJgffdER1a25Kbi54OYxPpdKxsdr/vdGzVYZrIcpW/oSbSfkiDaufWihVo2sZvjRKjEF5JrdTa8d1R2p7H4Fvg72Qey/WPVncp8KIj/swj+JDAtIgIAoGb/YffITq7wB7uH/ZU1GvZGQt9uyarxT6sF2LM3SfEMHlK7+a9x75/tVM6AQdpOUoZ4q88HCZxc1SZQot8XSaLljPRAzydDycQUxkUizfUZ5dbLiwSS0uDGZNlryapHU9c7gUzFqmvexE+SfGikoOcTZ6hAuUJoXj4ZcnWpKP8g87BE6wTl2WxlOWS4/wimMcq9bztARDP5cryGbDAD8Mn6gYzk4Km4f6eqhL1rzIeN3ssm/NmOAwDKylHpzfSDLLB/uru8EcBnrzZdryQj/k7GcIzTct2/PeJOh2F9m6fGaggDxZG5x0WCZ9inpjIkdn/E0q9a3oA==","associated_data":"transaction","nonce":"X4hb6wDif5Yo"}}

其中通知数据resource中的数据密文ciphertext为加密数据

③ 应答失败

对后台通知交互时,如果微信收到商户的应答不符合规范或超时,微信认为通知失败,微信会通过一定的策略定期重新发起通知,尽可能提高通知的成功率,但微信不保证通知最终能成功。(通知频率为15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h - 总计 24h4m)

用失败应答替换成功应答。

1
2
3
4
5
6
7
8
9
10
11
12
13
try {
// 成功应答
response.setStatus(200);
map.put("code", "SUCCESS");
map.put("message", "成功");
return gson.toJson(map);
} catch (Exception e) {
// 失败应答
response.setStatus(500);
map.put("code", "ERROR");
map.put("message", "失败");
return gson.toJson(map);
}
④ 超时应答

回调通知注意事项:https://pay.weixin.qq.com/wiki/doc/apiv3/Practices/chapter1_1_5.shtml

商户系统收到支付结果通知,需要在 5秒内 返回应答报文,否则微信支付认为通知失败,后续会重复发送通知。

4.6.3 验签

参考SDK源码中的 WechatPay2Validator 创建通知验签工具类 WechatPay2ValidatorForRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
public class WechatPay2ValidatorForRequest {

protected static final Logger log = LoggerFactory.getLogger(WechatPay2ValidatorForRequest.class);
/**
* 应答超时时间,单位为分钟
*/
protected static final long RESPONSE_EXPIRED_MINUTES = 5;
protected final Verifier verifier;
protected final String requestId;
protected final String body;


public WechatPay2ValidatorForRequest(Verifier verifier, String requestId, String body) {
this.verifier = verifier;
this.requestId = requestId;
this.body = body;
}

protected static IllegalArgumentException parameterError(String message, Object... args) {
message = String.format(message, args);
return new IllegalArgumentException("parameter error: " + message);
}

protected static IllegalArgumentException verifyFail(String message, Object... args) {
message = String.format(message, args);
return new IllegalArgumentException("signature verify fail: " + message);
}

public final boolean validate(HttpServletRequest request) throws IOException {
try {
//处理请求参数
validateParameters(request);

//构造验签名串
String message = buildMessage(request);

String serial = request.getHeader(WECHAT_PAY_SERIAL);
String signature = request.getHeader(WECHAT_PAY_SIGNATURE);

//验签
if (!verifier.verify(serial, message.getBytes(StandardCharsets.UTF_8), signature)) {
throw verifyFail("serial=[%s] message=[%s] sign=[%s], request-id=[%s]",
serial, message, signature, requestId);
}
} catch (IllegalArgumentException e) {
log.warn(e.getMessage());
return false;
}

return true;
}

protected final void validateParameters(HttpServletRequest request) {

// NOTE: ensure HEADER_WECHAT_PAY_TIMESTAMP at last
String[] headers = {WECHAT_PAY_SERIAL, WECHAT_PAY_SIGNATURE, WECHAT_PAY_NONCE, WECHAT_PAY_TIMESTAMP};

String header = null;
for (String headerName : headers) {
header = request.getHeader(headerName);
if (header == null) {
throw parameterError("empty [%s], request-id=[%s]", headerName, requestId);
}
}

//判断请求是否过期
String timestampStr = header;
try {
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestampStr));
// 拒绝过期请求
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= RESPONSE_EXPIRED_MINUTES) {
throw parameterError("timestamp=[%s] expires, request-id=[%s]", timestampStr, requestId);
}
} catch (DateTimeException | NumberFormatException e) {
throw parameterError("invalid timestamp=[%s], request-id=[%s]", timestampStr, requestId);
}
}

protected final String buildMessage(HttpServletRequest request) throws IOException {
String timestamp = request.getHeader(WECHAT_PAY_TIMESTAMP);
String nonce = request.getHeader(WECHAT_PAY_NONCE);
return timestamp + "\n"
+ nonce + "\n"
+ body + "\n";
}

protected final String getResponseBody(CloseableHttpResponse response) throws IOException {
HttpEntity entity = response.getEntity();
return (entity != null && entity.isRepeatable()) ? EntityUtils.toString(entity) : "";
}

}

验签:

1
2
3
4
5
6
7
8
9
10
11
12
// 签名的验证
WechatPay2ValidatorForRequest wechatPay2ValidatorForRequest
= new WechatPay2ValidatorForRequest(verifier, requestId, body);
if (!wechatPay2ValidatorForRequest.validate(request)) {
log.info("通知验签失败");
// 失败应答
response.setStatus(500);
map.put("code", "ERROR");
map.put("message", "通知验签失败");
return gson.toJson(map);
}
log.info("通知验签成功");
1
2
3
支付通知的id ====> 33c49451-8424-5853-8274-3e17e58a2611
支付通知完整的数据 ====> {"id":"33c49451-8424-5853-8274-3e17e58a2611","create_time":"2022-07-01T15:20:56+08:00","resource_type":"encrypt-resource","event_type":"TRANSACTION.SUCCESS","summary":"支付成功","resource":{"original_type":"transaction","algorithm":"AEAD_AES_256_GCM","ciphertext":"CxjRwo0bmTJ2tgdjYTaw6ByIZ1FhzDJTxhDPtCLsFksVELbe3x3ApHNiFvVb8JsWRoPOYY3DLTOtwA9rguy04YK2brTDRBO5Jj6ahDlsdbtb2VBnn1hdj3a6SDqFJr1DIfNRnzJy/IqMgaDaPsKfW3yV6BxAtnF33qAUM30Lb5ACTd7epqzPxC9g0/egbIO9g421JqRbXTRjrS0f3qB7sI+5CZKiIzTSmMQJ7Ls5NWxmYIJ+jSQjwGq6iHMrubiekbZ1YQhMu5zTU3UIHT+3NjucZ3nT3DNgr0GlM6hbR8AvzfOqbSSOT8UalL1qPlUeH/Dzzg54C5D0Zh0K/gNTtcpYvKtZTGcElpW39WZAvJgK7nb6P+vbkouYg2FZ7Kc+0vGQ320q2hm/mRpp6RyVEKbzgcq3AD3ork5DWjfOoCdZJTmgop832kGG/Qsz1EU57lRuEbxG6yrq64dvjYrEMP+n3YMCmZQ2QbNqKVEPlhYWKX/7Ytus5HIOECGv41RBAjD18TcYXx/dSNUjjwMQhlmHpUsVeQHZJOUHifYYQ5D+0EoTj01DxRMsJjGaxIeUpFCtHppk0g==","associated_data":"transaction","nonce":"5piNQYKjAr3Z"}}
通知验签成功

4.6.4 解密

微信服务器向我们发送支付通知的数据如下:

参数名 变量 类型[长度限制] 必填 描述
通知ID id string[1,36] 通知的唯一ID 示例值:EV-2018022511223320873
通知创建时间 create_time string[1,32] 通知创建的时间,遵循rfc3339标准格式,格式为yyyy-MM-DDTHH:mm:ss+TIMEZONE,yyyy-MM-DD表示年月日,T出现在字符串中,表示time元素的开头,HH:mm:ss.表示时分秒,TIMEZONE表示时区(+08:00表示东八区时间,领先UTC 8小时,即北京时间)。例如:2015-05-20T13:29:35+08:00表示北京时间2015年05月20日13点29分35秒。 示例值:2015-05-20T13:29:35+08:00
通知类型 event_type string[1,32] 通知的类型,支付成功通知的类型为TRANSACTION.SUCCESS 示例值:TRANSACTION.SUCCESS
通知数据类型 resource_type string[1,32] 通知的资源数据类型,支付成功通知为encrypt-resource 示例值:encrypt-resource
通知数据 resource object 通知资源数据 json格式,见示例
回调摘要 summary string[1,64] 回调摘要 示例值:支付成功

其中通知数据resource为嵌套结构,且数据密文ciphertext为加密数据:

参数名 变量 类型[长度限制] 必填 描述
加密算法类型 algorithm string[1,32] 对开启结果数据进行加密的加密算法,目前只支持AEAD_AES_256_GCM 示例值:AEAD_AES_256_GCM
数据密文 ciphertext string[1,1048576] Base64编码后的开启/停用结果数据密文 示例值:sadsadsadsad
附加数据 associated_data string[1,16] 附加数据 示例值:fdasfwqewlkja484w
原始类型 original_type string[1,16] 原始回调类型,为transaction 示例值:transaction
随机串 nonce string[1,16] 加密使用的随机串 示例值:fdasflkja484w

示意图:即为图中APIv3秘钥加密和解密的过程

image-20220701152224051

参数解密

  1. 用商户平台上设置的APIv3密钥【微信商户平台—>账户设置—>API安全—>设置APIv3密钥】,记为key;
  2. 针对resource.algorithm中描述的算法(目前为AEAD_AES_256_GCM),取得对应的参数nonce和associated_data;
  3. 使用key、nonce和associated_data,对数据密文resource.ciphertext进行解密,得到JSON形式的资源对象;

解密工具

可直接使用SDK中的AesUtil进行解密

代码

  • WxPayController:nativeNotify 方法中添加处理订单的代码
1
2
// 处理订单
wxPayService.processOrder(bodyMap);
  • WxPayService

接口:

1
void processOrder(Map<String, Object> bodyMap) throws GeneralSecurityException;

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Override
public void processOrder(Map<String, Object> bodyMap) throws GeneralSecurityException {
log.info("处理订单");
String plain = decryptFromResource(bodyMap);
}

/**
* 对称解密
* @param bodyMap
* @return
*/
private String decryptFromResource(Map<String, Object> bodyMap) throws GeneralSecurityException {
log.info("密文解密");
// 通知数据
Map<String, String> resourceMap = (Map) bodyMap.get("resource");
// 数据密文
String ciphertext = resourceMap.get("ciphertext");
// 随机串
String nonce = resourceMap.get("nonce");
// 附加数据
String associatedData = resourceMap.get("associated_data");
// 使用SDK提供的解密工具,参数为商户的私钥,从配置文件中读取
AesUtil aesUtil = new AesUtil(wxPayConfig.getApiV3Key().getBytes(StandardCharsets.UTF_8));
// 解密
// 所需参数分别为associatedData,nonce和带解密的密文
String plainText = aesUtil.decryptToString(associatedData.getBytes(StandardCharsets.UTF_8),
nonce.getBytes(StandardCharsets.UTF_8),
ciphertext);
log.info("密文 ===> {}", ciphertext);
log.info("明文 ===> {}", plainText);
return plainText;
}

测试打印结果:

1
2
3
4
5
6
7
支付通知的id ====> 747e4c86-b790-5094-b31e-34133c47a14c
支付通知完整的数据 ====> {"id":"747e4c86-b790-5094-b31e-34133c47a14c","create_time":"2022-07-01T15:34:30+08:00","resource_type":"encrypt-resource","event_type":"TRANSACTION.SUCCESS","summary":"支付成功","resource":{"original_type":"transaction","algorithm":"AEAD_AES_256_GCM","ciphertext":"ErkVM9rUETVtlabIdx8GI66M8WuDnF/FidzhnA9ALFZW0sE1KRxTQJi+QLCTsVHp+WT5WLtXNB/cnbJZGB/zwcRom6FQN1UFYtzvH6u9tA/FYGlmYMXZk0MvVuHxbp086H8dbZCRgfZIaPDFeKuKHMpHNIDT9ipUuUyTerHl5AdTuAw+xCjJvpMB2DC809ZrZhraL6Ss/4kaCZTirlWxvpTkpsaaBIcEhWUBsB2LGX3/3Ir8PEP2hVC3PJj8Vgn9/8UIrYOM6rR0y+k+SGupTwLqeP5jlKyvfkL6UL1u29N1i9uQ5hRKuiScOfeGAWvntrYcYFV1wUx6UW/2JjBphT8eoTYh5Fi4irM52gh5cSo4s7FtCMGI//jZE9/iorBgkayg4noY3dMx1ASUtbLTqnKUaBA3sRajH22n+AcikG3UINIwYwTbg6horsHJoQnoNYTZT3/4gHMljrjfzQ1/CzASocyAm4lOJRiwbliLvX5BYInWEWHEUMf9TSdxsgDVg7bSKL3u9Km0XnS+eiQ6fR1QzZxxmWzLmPBpyql1uu4s90y48hRRZI9VG9eoUC4DqJR8e0FhJQ==","associated_data":"transaction","nonce":"LZbjBQxAGWVG"}}
通知验签成功
处理订单
密文解密
密文 ===> ErkVM9rUETVtlabIdx8GI66M8WuDnF/FidzhnA9ALFZW0sE1KRxTQJi+QLCTsVHp+WT5WLtXNB/cnbJZGB/zwcRom6FQN1UFYtzvH6u9tA/FYGlmYMXZk0MvVuHxbp086H8dbZCRgfZIaPDFeKuKHMpHNIDT9ipUuUyTerHl5AdTuAw+xCjJvpMB2DC809ZrZhraL6Ss/4kaCZTirlWxvpTkpsaaBIcEhWUBsB2LGX3/3Ir8PEP2hVC3PJj8Vgn9/8UIrYOM6rR0y+k+SGupTwLqeP5jlKyvfkL6UL1u29N1i9uQ5hRKuiScOfeGAWvntrYcYFV1wUx6UW/2JjBphT8eoTYh5Fi4irM52gh5cSo4s7FtCMGI//jZE9/iorBgkayg4noY3dMx1ASUtbLTqnKUaBA3sRajH22n+AcikG3UINIwYwTbg6horsHJoQnoNYTZT3/4gHMljrjfzQ1/CzASocyAm4lOJRiwbliLvX5BYInWEWHEUMf9TSdxsgDVg7bSKL3u9Km0XnS+eiQ6fR1QzZxxmWzLmPBpyql1uu4s90y48hRRZI9VG9eoUC4DqJR8e0FhJQ==
明文 ===> {"mchid":"1558950191","appid":"wx74862e0dfcf69954","out_trade_no":"ORDER_20220701153417417","transaction_id":"4200001495202207013608959201","trade_type":"NATIVE","trade_state":"SUCCESS","trade_state_desc":"支付成功","bank_type":"OTHERS","attach":"","success_time":"2022-07-01T15:34:30+08:00","payer":{"openid":"oHwsHuJmaePDfboPZ03hte8nmmbg"},"amount":{"total":1,"payer_total":1,"currency":"CNY","payer_currency":"CNY"}}

明文的json格式为:重要参数已经注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"mchid":"1558950191",
"appid":"wx74862e0dfcf69954",
"out_trade_no":"ORDER_20220701153417417", // 订单号
"transaction_id":"4200001495202207013608959201", // 业务编号
"trade_type":"NATIVE","trade_state":"SUCCESS", // 支付类型
"trade_state_desc":"支付成功", // 交易状态
"bank_type":"OTHERS",
"attach":"",
"success_time":"2022-07-01T15:34:30+08:00",
"payer":
{
"openid":"oHwsHuJmaePDfboPZ03hte8nmmbg"
},
"amount":
{
"total":1, // 总金额
"payer_total":1, // 用户支付的金额(可能减去了一些红包之类的优惠)
"currency":"CNY",
"payer_currency":"CNY"
}
}

4.6.5 处理订单

① 完善processOrder方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public void processOrder(Map<String, Object> bodyMap) throws GeneralSecurityException {
log.info("处理订单");
// 解密报文
String plainText = decryptFromResource(bodyMap);
// 将明文转换为map
Gson gson = new Gson();
HashMap plainTextMap = gson.fromJson(plainText, HashMap.class);
String orderNo = (String)plainTextMap.get("out_trade_no"); // 订单号
// 更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);
// 记录支付日志
paymentInfoService.createPaymentInfo(plainTextMap);
}
② 更新订单状态

OrderInfoService:

接口:

1
2
3
4
5
6
/**
* 根据订单号更新订单状态
* @param orderNo
* @param orderStatus
*/
void updateStatusByOrderNo(String orderNo, OrderStatus orderStatus);

实现:

1
2
3
4
5
6
7
8
9
@Override
public void updateStatusByOrderNo(String orderNo, OrderStatus orderStatus) {
log.info("更新订单状态 ====> {}", orderStatus.getType());
QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("order_no", orderNo);
OrderInfo orderInfo = new OrderInfo();
orderInfo.setOrderStatus(orderStatus.getType());
orderInfoMapper.update(orderInfo, queryWrapper);
}
③ 新增支付日志

PaymentInfoService

接口:

1
2
3
4
5
/**
* 根据解密的明文创建支付日志
* @param plainTextMap
*/
void createPaymentInfo(HashMap plainTextMap);

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
@Slf4j
public class PaymentInfoServiceImpl extends ServiceImpl<PaymentInfoMapper, PaymentInfo> implements PaymentInfoService {

@Resource
private PaymentInfoMapper paymentInfoMapper;


@Override
public void createPaymentInfo(HashMap plainTextMap) {
log.info("创建支付日志");
// 订单号
String orderNo = (String)plainTextMap.get("out_trade_no");
// 微信端的业务编号
String transactionId = (String)plainTextMap.get("transaction_id");
// 交易类型
String tradeType = (String)plainTextMap.get("trade_type");
// 交易状态
String tradeState = (String)plainTextMap.get("trade_state");
// 用户支付的金额
Map<String, Object> amount = (Map)plainTextMap.get("amount");
Integer payerTotal = ((Double) amount.get("payer_total")).intValue();
// 新建支付订单
PaymentInfo paymentInfo = new PaymentInfo();
paymentInfo.setOrderNo(orderNo);
paymentInfo.setPaymentType(PayType.WXPAY.getType());
paymentInfo.setTransactionId(transactionId);
paymentInfo.setTradeType(tradeType);
paymentInfo.setTradeState(tradeState);
paymentInfo.setPayerTotal(payerTotal);
// 将所有数据存在一个字段里
Gson gson = new Gson();
String plainText = gson.toJson(plainTextMap);
paymentInfo.setContent(plainText);
paymentInfoMapper.insert(paymentInfo);
}
}

测试打印结果:sql打印日志略

1
2
更新订单状态 ====> 支付成功
创建支付日志

4.6.6 处理重复通知

• 同样的通知可能会多次发送给商户系统。商户系统必须能够正确处理重复的通知。 推荐的做法是,当商户系统收到通知进行处理时,先检查对应业务数据的状态,并判断该通知是否已经处理。如果未处理,则再进行处理;如果已处理,则直接返回结果成功。在对业务数据进行状态检查和处理之前,要采用数据锁进行并发控制,以避免函数重入造成的数据混乱。

• 如果在所有通知频率后没有收到微信侧回调,商户应调用查询订单接口确认订单状态。

在 processOrder 方法中,更新订单状态之前,添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 处理重复的通知
// 保证接口调用的幂等性:无论接口被调用多少次,产生的结果是一致的
String orderStatus = orderInfoService.getOrderStatus(orderNo);
// 如果当前订单尚未支付,则不做处理
if (OrderStatus.NOTPAY.getType().equals(orderStatus)) {
return ;
}
// 如果用户支付了订单,则将数据插入数据库
// 更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);
// 记录支付日志
paymentInfoService.createPaymentInfo(plainTextMap);

OrderInfoService

接口:

1
2
3
4
5
6
/**
* 根据订单号查询订单状态
* @param orderNo
* @return
*/
String getOrderStatus(String orderNo);

实现:

1
2
3
4
5
6
7
8
9
10
@Override
public String getOrderStatus(String orderNo) {
QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("order_no", orderNo);
OrderInfo orderInfo = orderInfoMapper.selectOne(queryWrapper);
if (orderInfo == null) {
return null;
}
return orderInfo.getOrderStatus();
}

4.6.7 数据锁

在对业务数据进行状态检查和处理之前,要采用数据锁进行并发控制,以避免函数重入造成的数据混乱。

对上一节的代码:如果用户支付订单后,有两个并发的请求调用了该接口,都会进行如下处理:

1
2
3
4
// 更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);
// 记录支付日志
paymentInfoService.createPaymentInfo(plainTextMap);

导致数据库中数据混乱和冗余。

解决:定义 ReentrantLock 进行并发控制。注意,必须手动释放锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private final ReentrantLock lock = new ReentrantLock();

// 数据锁:成功获取则返回true,失败获取则立即返回false,不会一直等待锁的释放
if (lock.tryLock()) {
try {
// 处理重复的通知
// 保证接口调用的幂等性:无论接口被调用多少次,产生的结果是一致的
String orderStatus = orderInfoService.getOrderStatus(orderNo);
// 如果当前订单已经支付,则不做处理
if (!OrderStatus.NOTPAY.getType().equals(orderStatus)) {
return ;
}
// 如果用户尚未支付订单,则将数据插入数据库
// 更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);
// 记录支付日志
paymentInfoService.createPaymentInfo(plainTextMap);
} finally {
// 释放锁
lock.unlock();
}
}

4.7 商户定时查询本地订单

需求:用户在未扫码时,二维码一直存在页面上,用户扫码并完成支付后,二维码消失。因此前端必须定时调用后端的查询订单状态接口。

后端定义商户查单接口

OrderInfoController

支付成功后,商户侧查询本地数据库,订单是否支付成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 查询订单状态
* @param orderNo
* @return
*/
@GetMapping("/query-order-status/{orderNo}")
public R queryOrderStatus(@PathVariable String orderNo) {
String orderStatus = orderInfoService.getOrderStatus(orderNo);
if (OrderStatus.SUCCESS.getType().equals(orderStatus)) {
return R.ok().setMessage("支付成功"); //支付成功
}
// code: 1为支付成功,0为支付失败,101为任意值
return R.ok().setCode(101).setMessage("用户支付中....");
}

前端定时轮询查单

在二维码展示页面,前端定时轮询查询订单是否已支付(调用上述接口),如果支付成功则跳转到订单页面。

关键代码:

  • index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<script>
export default {

data () {
return {
payBtnDisabled: false, //确认支付按钮是否禁用
codeDialogVisible: false, //微信支付二维码弹窗
productList: [], //商品列表
payOrder: { //订单信息
productId: '', //商品id
payType: 'wxpay' //支付方式
},
codeUrl: '', // 二维码
orderNo: '', //订单号
timer: null // 定时器
}
},

methods: {
//确认支付
toPay(){
this.payBtnDisabled = true

//微信支付
if(this.payOrder.payType === 'wxpay'){
//调用统一下单接口
wxPayApi.nativePay(this.payOrder.productId).then(response => {
this.codeUrl = response.data.codeUrl
this.orderNo = response.data.orderNo
this.codeDialogVisible = true

// 启动定时器,每3s调用后端接口
this.timer = setInterval(() => {
// 查询订单是否支付成功
this.queryOrderStatus()
}, 3000)
})
}
},

//关闭微信支付二维码对话框时启用“确认支付”按钮
closeDialog(){
console.log('close.................')
this.payBtnDisabled = false
console.log('清除定时器')
clearInterval(this.timer)
},

// 查询订单状态
queryOrderStatus() {
// 根据订单号调用后端接口
orderInfoApi.queryOrderStatus(this.orderNo).then(response => {
console.log('查询订单状态:' + response.code)

// 支付成功后的页面跳转
if (response.code === 0) {
console.log('清除定时器')
clearInterval(this.timer)
// 三秒后跳转到订单列表
setTimeout(() => {
this.$router.push({ path: '/orders' })
}, 3000)
}
})
}
}
}
</script>
  • orderInfoApi.js
1
2
3
4
5
6
7
8
export default{
queryOrderStatus(orderNo) {
return request({
url: '/api/order-info/query-order-status/' + orderNo,
method: 'get'
})
}
}

前端控制台打印结果

image-20220701174607703

image-20220701174623229

4.8 用户取消订单API

4.8.1 接口定义

适用对象: 直连商户

请求URL: https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}/close

请求方式: POST

请求参数:

参数名 变量 类型[长度限制] 必填 描述
直连商户号 mchid string[1,32] body 直连商户的商户号,由微信支付生成并下发。
商户订单号 out_trade_no string[6,32] path 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一

4.8.2 业务层

WxPayService

接口:

1
void cancelOrder(String orderNo) throws IOException;

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Override
public void cancelOrder(String orderNo) throws IOException {
// 调用微信支付的关闭订单接口
this.closeOrder(orderNo);
// 更新商户端的订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.CANCEL);
}

/**
* 关闭订单接口的调用
* @param orderNo
*/
private void closeOrder(String orderNo) throws IOException {
log.info("关单接口的调用,订单号 ====> {}", orderNo);
// 创建远程请求对象
String url = String.format(WxApiType.CLOSE_ORDER_BY_NO.getType(), orderNo);
url = wxPayConfig.getDomain().concat(url);
HttpPost httpPost = new HttpPost(url);
// 组装json请求体
Gson gson = new Gson();
HashMap<String, String> paramsMap = new HashMap<>();
paramsMap.put("mchid", wxPayConfig.getMchId());
String jsonParams = gson.toJson(paramsMap);
log.info("请求参数 ====> {}", jsonParams);
// 将请求参数设置到请求对象中
StringEntity entity = new StringEntity(jsonParams,"utf-8");
entity.setContentType("application/json");
httpPost.setEntity(entity);
httpPost.setHeader("Accept", "application/json");

// 完成签名并执行请求
CloseableHttpResponse response = httpClient.execute(httpPost);

try {
// 获取响应状态码
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 200) { //处理成功
log.info("success200");
} else if (statusCode == 204) { //处理成功,无返回Body
log.info("success204");
} else {
log.info("failed,resp code = " + statusCode);
throw new IOException("request failed");
}
} finally {
response.close();
}
}

4.8.3 控制层

1
2
3
4
5
6
@PostMapping("/cancel/{orderNo}")
public R cancel(@PathVariable String orderNo) throws IOException {
log.info("取消订单");
wxPayService.cancelOrder(orderNo);
return R.ok().setMessage("订单已取消");
}

测试结果:

1
2
3
4
5
c.h.p.controller.WxPayController         : 取消订单
c.h.p.service.impl.WxPayServiceImpl : 关单接口的调用,订单号 ====> ORDER_20220704105147845
c.h.p.service.impl.WxPayServiceImpl : 请求参数 ====> {"mchid":"1558950191"}
c.h.p.service.impl.WxPayServiceImpl : success204
c.h.p.service.impl.OrderInfoServiceImpl : 更新订单状态 ====> 用户已取消

4.9 微信支付查单API

商户后台未收到异步支付结果通知时,商户应该主动调用微信支付的查单接口,同步订单状态。

4.9.1 接口定义

微信支付提供了两种方式供我们查询订单。

① 微信支付订单号查询

请求URL: https://api.mch.weixin.qq.com/v3/pay/transactions/id/{transaction_id}

请求方式:GET

请求参数

参数名 变量 类型[长度限制] 必填 描述
直连商户号 mchid string[1,32] query 直连商户的商户号,由微信支付生成并下发。
微信支付订单号 transaction_id string[1,32] path 微信支付系统生成的订单号

示例:https://api.mch.weixin.qq.com/v3/pay/transactions/id/1217752501201407033233368018?mchid=1230000109

② 商户订单号查询

请求URL: https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}

请求方式:GET

请求参数

参数名 变量 类型[长度限制] 必填 描述
直连商户号 mchid string[1,32] query 直连商户的商户号,由微信支付生成并下发。
商户订单号 out_trade_no string[6,32] path 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一。 特殊规则:最小字符长度为6

示例:https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/1217752501201407033233368018?mchid=1230000109

返回参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"amount": {
"currency": "CNY",
"payer_currency": "CNY",
"payer_total": 1,
"total": 1
},
"appid": "wxdace645e0bc2cXXX",
"attach": "",
"bank_type": "OTHERS",
"mchid": "1900006XXX",
"out_trade_no": "44_2126281063_5504",
"payer": {
"openid": "o4GgauJP_mgWEWictzA15WT15XXX"
},
"promotion_detail": [],
"success_time": "2021-03-22T10:29:05+08:00",
"trade_state": "SUCCESS",
"trade_state_desc": "支付成功",
"trade_type": "JSAPI",
"transaction_id": "4200000891202103228088184743"
}

4.9.2 查单接口的调用

① 业务层

接口:

1
String queryOrder(String orderNo) throws IOException;

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
public String queryOrder(String orderNo) throws IOException {
log.info("查单接口调用 ====> {}", orderNo);
String url = String.format(WxApiType.ORDER_QUERY_BY_NO.getType(), orderNo);
url = wxPayConfig.getDomain().concat(url).concat("?mchid=").concat(wxPayConfig.getMchId());
HttpGet httpGet = new HttpGet(url);
httpGet.setHeader("Accept", "application/json");
// 完成签名并执行请求
CloseableHttpResponse response = httpClient.execute(httpGet);
try {
// 获取响应体
String bodyAsString = EntityUtils.toString(response.getEntity());
// 获取响应状态码
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 200) { //处理成功
log.info("success,return body = " + bodyAsString);
} else if (statusCode == 204) { //处理成功,无返回Body
log.info("success");
} else {
log.info("failed,resp code = " + statusCode+ ",return body = " + bodyAsString);
throw new IOException("request failed");
}
return bodyAsString;
} finally {
response.close();
}
}
② 控制层
1
2
3
4
5
6
@GetMapping("/query/{orderNo}")
public R queryOrder(@PathVariable String orderNo) throws IOException {
log.info("查询订单");
String result = wxPayService.queryOrder(orderNo);
return R.ok().setMessage("查询成功").data("result", result);
}

测试:

image-20220704113145109

1
2
3
查询订单
查单接口调用 ====> ORDER_20220701174001279
success,return body = {"amount":{"currency":"CNY","payer_currency":"CNY","payer_total":1,"total":1},"appid":"wx74862e0dfcf69954","attach":"","bank_type":"OTHERS","mchid":"1558950191","out_trade_no":"ORDER_20220701174001279","payer":{"openid":"oHwsHuJmaePDfboPZ03hte8nmmbg"},"promotion_detail":[],"success_time":"2022-07-01T17:40:13+08:00","trade_state":"SUCCESS","trade_state_desc":"支付成功","trade_type":"NATIVE","transaction_id":"4200001513202207010026399687"}

4.9.3 引入定时任务

Spring 3.0后提供Spring Task实现任务调度

  • 启动类添加注解
1
@EnableScheduling
  • 测试定时任务

新建包task

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
@Slf4j
public class WxPayTask {
/**
* 秒 分 时 日 月 周
* * 每秒都执行
* 1-3: 从第1s开始执行,到第3s结束执行
* 0/3: 从第0s开始,每隔3s执行1次
* 1,2,3: 在指定的第1,2,3s执行1次
* 日和周不能同时指定,?为不指定
*/
@Scheduled(cron = "0/3 * * * * ?")
public void task1() {
log.info("task1被执行...");
}
}

4.9.4 定时查找超时订单

  • OrderInfoService

接口:

1
2
3
4
5
6
/**
* 本地查询超过minutes分钟的未支付订单
* @param minutes
* @return
*/
List<OrderInfo> getNoPayOrderByDuration(int minutes);

实现:

1
2
3
4
5
6
7
8
@Override
public List<OrderInfo> getNoPayOrderByDuration(int minutes) {
Instant instant = Instant.now().minus(Duration.ofMinutes(minutes));
QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("order_status", OrderStatus.NOTPAY.getType())
.le("create_time", instant);
return orderInfoMapper.selectList(queryWrapper);
}
  • WxPayTask
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 每隔30s执行一次,查询创建超过1分钟,并且未支付的订单
*/
@Scheduled(cron = "0/30 * * * * ?")
public void orderConfirm() {
log.info("orderConfirm被执行...");
List<OrderInfo> orderInfoList = orderInfoService.getNoPayOrderByDuration(1);
for (OrderInfo orderInfo : orderInfoList) {
log.warn("超时订单 ====> {}", orderInfo.getOrderNo());
// 核实订单状态:调用微信支付查单接口
wxPayService.checkOrderStatus(orderNo);
}
}

4.9.5 处理超时订单

  • WxPayService:核实订单状态

接口:

1
2
3
4
5
6
7
/**
* 根据订单号查询微信支付查单接口,核实订单状态
* 如果订单已经支付,则更新商户端订单状态
* 如果订单未支付,则调用关单接口,并更新商户端订单状态
* @param orderNo
*/
void checkOrderStatus(String orderNo) throws IOException;

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public void checkOrderStatus(String orderNo) throws IOException {
log.warn("根据订单号核实订单状态 ====> {}", orderNo);
// 调用查单接口
String result = this.queryOrder(orderNo);
Gson gson = new Gson();
Map resultMap = gson.fromJson(result, HashMap.class);
// 获取微信支付端的订单状态
Object tradeState = resultMap.get("trade_state");
// 判断订单状态
if (WxTradeState.SUCCESS.getType().equals(tradeState)) {
log.warn("核实订单已支付 ====> {}", orderNo);
// 更新本地的订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);
// 记录支付日志
paymentInfoService.createPaymentInfo(gson.fromJson(result, HashMap.class));
}
if (WxTradeState.NOTPAY.getType().equals(tradeState)) {
log.info("核实订单未支付 ====> {}", tradeState);
// 调用关闭订单接口
this.closeOrder(orderNo);
// 更新本地的订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.CLOSED);
}
}

测试

步骤:首先选择java课程,不支付,再选择大数据课程,在支付前关闭ngrok,以下为打印结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# -------java课程点击支付按钮后-------
2022-07-04 15:07:25.116 INFO 24168 --- [nio-8090-exec-3] c.h.p.controller.WxPayController : 发起支付请求
2022-07-04 15:07:25.116 INFO 24168 --- [nio-8090-exec-3] c.h.p.service.impl.WxPayServiceImpl : 生成订单
2022-07-04 15:07:25.129 INFO 24168 --- [nio-8090-exec-3] c.h.p.service.impl.WxPayServiceImpl : 调用统一下单API
2022-07-04 15:07:25.132 INFO 24168 --- [nio-8090-exec-3] c.h.p.service.impl.WxPayServiceImpl : 请求参数: {"amount":{"total":1,"currency":"CNY"},"mchid":"1558950191","out_trade_no":"ORDER_20220704150725928","appid":"wx74862e0dfcf69954","description":"Java课程","notify_url":"https://84bb-117-174-85-8.ap.ngrok.io/api/wx-pay/native/notify"}
2022-07-04 15:07:25.540 INFO 24168 --- [nio-8090-exec-3] c.h.p.service.impl.WxPayServiceImpl : success,return body = {"code_url":"weixin://wxpay/bizpayurl?pr=2cEYVP1zz"}
# -------定时任务每30s执行1次-------
[org.apache.ibatis.session.defaults.DefaultSqlSession@2ff8f25]
2022-07-04 15:07:30.002 INFO 24168 --- [ scheduling-1] com.hongyi.paymentdemo.task.WxPayTask : orderConfirm被执行...
[org.apache.ibatis.session.defaults.DefaultSqlSession@4996e096]
2022-07-04 15:08:00.001 INFO 24168 --- [ scheduling-1] com.hongyi.paymentdemo.task.WxPayTask : orderConfirm被执行...
# -------大数据课程点击支付按钮-------
2022-07-04 15:08:06.692 INFO 24168 --- [nio-8090-exec-7] c.h.p.controller.WxPayController : 发起支付请求
2022-07-04 15:08:06.692 INFO 24168 --- [nio-8090-exec-7] c.h.p.service.impl.WxPayServiceImpl : 生成订单
2022-07-04 15:08:06.701 INFO 24168 --- [nio-8090-exec-7] c.h.p.service.impl.WxPayServiceImpl : 调用统一下单API
2022-07-04 15:08:06.702 INFO 24168 --- [nio-8090-exec-7] c.h.p.service.impl.WxPayServiceImpl : 请求参数: {"amount":{"total":1,"currency":"CNY"},"mchid":"1558950191","out_trade_no":"ORDER_20220704150806558","appid":"wx74862e0dfcf69954","description":"大数据课程","notify_url":"https://84bb-117-174-85-8.ap.ngrok.io/api/wx-pay/native/notify"}
2022-07-04 15:08:07.132 INFO 24168 --- [nio-8090-exec-7] c.h.p.service.impl.WxPayServiceImpl : success,return body = {"code_url":"weixin://wxpay/bizpayurl?pr=q8LeIcFzz"}
# -------本地查询到java订单超过1min未支付,向微信支付端查单,发现未支付,则关闭订单-------
2022-07-04 15:08:30.005 WARN 24168 --- [ scheduling-1] com.hongyi.paymentdemo.task.WxPayTask : 超时订单 ====> ORDER_20220704150725928
2022-07-04 15:08:30.005 WARN 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : 根据订单号核实订单状态 ====> ORDER_20220704150725928
2022-07-04 15:08:30.005 INFO 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : 查单接口调用 ====> ORDER_20220704150725928
2022-07-04 15:08:30.334 INFO 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : success,return body = {"amount":{"payer_currency":"CNY","total":1},"appid":"wx74862e0dfcf69954","mchid":"1558950191","out_trade_no":"ORDER_20220704150725928","promotion_detail":[],"scene_info":{"device_id":""},"trade_state":"NOTPAY","trade_state_desc":"订单未支付"}
2022-07-04 15:08:30.337 INFO 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : 核实订单未支付 ====> NOTPAY
2022-07-04 15:08:30.337 INFO 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : 关单接口的调用,订单号 ====> ORDER_20220704150725928
2022-07-04 15:08:30.338 INFO 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : 请求参数 ====> {"mchid":"1558950191"}
2022-07-04 15:08:30.590 INFO 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : success204
2022-07-04 15:08:30.590 INFO 24168 --- [ scheduling-1] c.h.p.service.impl.OrderInfoServiceImpl : 更新订单状态 ====> 超时已关闭
# -------关闭ngrok后,服务器接收不到微信的通知。本地查询到大数据订单超过1min未支付,向微信查单,得知已支付,则按照已支付的逻辑处理订单-------
2022-07-04 15:09:30.005 WARN 24168 --- [ scheduling-1] com.hongyi.paymentdemo.task.WxPayTask : 超时订单 ====> ORDER_20220704150806558
2022-07-04 15:09:30.005 WARN 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : 根据订单号核实订单状态 ====> ORDER_20220704150806558
2022-07-04 15:09:30.005 INFO 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : 查单接口调用 ====> ORDER_20220704150806558
2022-07-04 15:09:30.356 INFO 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : success,return body = {"amount":{"currency":"CNY","payer_currency":"CNY","payer_total":1,"total":1},"appid":"wx74862e0dfcf69954","attach":"","bank_type":"OTHERS","mchid":"1558950191","out_trade_no":"ORDER_20220704150806558","payer":{"openid":"oHwsHuJmaePDfboPZ03hte8nmmbg"},"promotion_detail":[],"success_time":"2022-07-04T15:08:27+08:00","trade_state":"SUCCESS","trade_state_desc":"支付成功","trade_type":"NATIVE","transaction_id":"4200001499202207043727479598"}
2022-07-04 15:09:30.357 WARN 24168 --- [ scheduling-1] c.h.p.service.impl.WxPayServiceImpl : 核实订单已支付 ====> ORDER_20220704150806558
2022-07-04 15:09:30.357 INFO 24168 --- [ scheduling-1] c.h.p.service.impl.OrderInfoServiceImpl : 更新订单状态 ====> 支付成功
2022-07-04 15:09:30.362 INFO 24168 --- [ scheduling-1] c.h.p.s.impl.PaymentInfoServiceImpl : 创建支付日志
# -------继续执行定时任务,查询本地的未支付订单-------
2022-07-04 15:10:00.001 INFO 24168 --- [ scheduling-1] com.hongyi.paymentdemo.task.WxPayTask : orderConfirm被执行...

4.10 申请退款API

文档:https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_4_9.shtml

4.10.1 接口说明

当交易发生之后一年内,由于买家或者卖家的原因需要退款时,卖家可以通过退款接口将支付金额退还给买家,微信支付将在收到退款请求并且验证成功之后,将支付款按原路退还至买家账号上。

image-20220704160144693

4.10.2 创建退款单

① 根据订单号查询订单

OrderInfoService

接口:

1
2
3
4
5
6
/**
* 根据订单号获得订单
* @param orderNo
* @return
*/
OrderInfo getOrderByOrderNo(String orderNo);

实现:

1
2
3
4
5
6
@Override
public OrderInfo getOrderByOrderNo(String orderNo) {
QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("order_no", orderNo);
return orderInfoMapper.selectOne(queryWrapper);
}
② 创建退款单记录

RefundInfoService

接口:

1
2
3
4
5
6
7
/**
* 根据订单号和退款原因创建退款单记录
* @param orderNo
* @param reason
* @return
*/
RefundInfo createRefundByOrderNo(String orderNo, String reason);

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
public class RefundInfoServiceImpl extends ServiceImpl<RefundInfoMapper, RefundInfo> implements RefundInfoService {

@Resource
private OrderInfoService orderInfoService;

@Resource
private RefundInfoMapper refundInfoMapper;

@Override
public RefundInfo createRefundByOrderNo(String orderNo, String reason) {
// 根据订单号获取订单信息
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
// 根据订单号生成退款单
RefundInfo refundInfo = new RefundInfo();
refundInfo.setOrderNo(orderNo);
refundInfo.setRefundNo(OrderNoUtils.getRefundNo());
refundInfo.setTotalFee(orderInfo.getTotalFee());
refundInfo.setRefund(orderInfo.getTotalFee());
refundInfo.setReason(reason);
// 保存
refundInfoMapper.insert(refundInfo);
return refundInfo;
}
}

4.10.3 更新退款单

RefundInfoService

接口:

1
2
3
4
5
/**
* 更新退款单
* @param content
*/
void updateRefund(String content);

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public void updateRefund(String content) {
//将json字符串转换成Map
Gson gson = new Gson();
Map<String, String> resultMap = gson.fromJson(content, HashMap.class);
//根据退款单编号修改退款单
QueryWrapper<RefundInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("refund_no", resultMap.get("out_refund_no"));
//设置要修改的字段
RefundInfo refundInfo = new RefundInfo();
refundInfo.setRefundId(resultMap.get("refund_id"));
//微信支付退款单号
//查询退款和申请退款中的返回参数
if(resultMap.get("status") != null){
refundInfo.setRefundStatus(resultMap.get("status"));//退款状态
refundInfo.setContentReturn(content);//将全部响应结果存入数据库的content字段
}
//退款回调中的回调参数
if(resultMap.get("refund_status") != null){
refundInfo.setRefundStatus(resultMap.get("refund_status")); //退款状态
refundInfo.setContentNotify(content);//将全部响应结果存入数据库的content字段
}
//更新退款单
baseMapper.update(refundInfo, queryWrapper);
}

4.10.4 申请退款

  • WxPayController
1
2
3
4
5
6
@PostMapping("/refunds/{orderNo}/{reason}")
public R refunds(@PathVariable String orderNo, @PathVariable String reason) throws IOException {
log.info("申请退款");
wxPayService.refund(orderNo, reason);
return R.ok();
}
  • WxPayService

接口:

1
2
3
4
5
6
/**
* 根据订单号退款
* @param orderNo
* @param reason
*/
void refund(String orderNo, String reason) throws IOException;

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public void refund(String orderNo, String reason) throws Exception {
log.info("创建退款单记录");
//根据订单编号创建退款单
RefundInfo refundsInfo = refundsInfoService.createRefundByOrderNo(orderNo, reason);
log.info("调用退款API");
//调用统一下单API
String url = wxPayConfig.getDomain().concat(WxApiType.DOMESTIC_REFUNDS.getType());
HttpPost httpPost = new HttpPost(url);
// 请求body参数
Gson gson = new Gson();
Map paramsMap = new HashMap();
paramsMap.put("out_trade_no", orderNo);//订单编号
paramsMap.put("out_refund_no", refundsInfo.getRefundNo());//退款单编号
paramsMap.put("reason",reason);//退款原因
paramsMap.put("notify_url", wxPayConfig.getNotifyDomain().concat(WxNotifyType.REFUND_NOTIFY.getType()));//退款通知地址
Map amountMap = new HashMap();
amountMap.put("refund", refundsInfo.getRefund());//退款金额
amountMap.put("total", refundsInfo.getTotalFee());//原订单金额
amountMap.put("currency", "CNY");//退款币种
paramsMap.put("amount", amountMap); //将参数转换成json字符串
String jsonParams = gson.toJson(paramsMap);
log.info("请求参数 ===> {}" + jsonParams);
StringEntity entity = new StringEntity(jsonParams,"utf-8");
entity.setContentType("application/json");//设置请求报文格式
httpPost.setEntity(entity);//将请求报文放入请求对象
httpPost.setHeader("Accept", "application/json");//设置响应报文格式
//完成签名并执行请求,并完成验签
CloseableHttpResponse response = wxPayClient.execute(httpPost);
try {
//解析响应结果
String bodyAsString = EntityUtils.toString(response.getEntity());
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 200) {
log.info("成功, 退款返回结果 = " + bodyAsString);
} else if (statusCode == 204) {
log.info("成功");
} else {
throw new RuntimeException("退款异常, 响应码 = " + statusCode+ ", 退款 返回结果 = " + bodyAsString);
}
//更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_PROCESSING);
//更新退款单
refundsInfoService.updateRefund(bodyAsString);
} finally {
response.close();
}
}

4.11 查询退款API

4.9节思想一致,也需要定时任务来查询退款订单是否已经完成退款,以此来修改订单和退款单的状态。

文档:https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_4_10.shtml

4.11.1 查单接口调用

  • WxPayService

接口:

1
2
3
4
5
6
/**
* 根据退款单号核实退款单状态
* @param refundNo
* @return
*/
String queryRefund(String refundNo);

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override 
public String queryRefund(String refundNo) throws Exception {
log.info("查询退款接口调用 ===> {}", refundNo);
String url = String.format(WxApiType.DOMESTIC_REFUNDS_QUERY.getType(), refundNo);
url = wxPayConfig.getDomain().concat(url);
//创建远程Get 请求对象
HttpGet httpGet = new HttpGet(url); httpGet.setHeader("Accept", "application/json");
//完成签名并执行请求
CloseableHttpResponse response = wxPayClient.execute(httpGet);
try {
String bodyAsString = EntityUtils.toString(response.getEntity());
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 200) {
log.info("成功, 查询退款返回结果 = " + bodyAsString);
} else if (statusCode == 204) {
log.info("成功");
} else {
throw new RuntimeException("查询退款异常, 响应码 = " + statusCode+ ", 查询退款返回结果 = " + bodyAsString);
}
return bodyAsString;
} finally {
response.close();
}
}
  • 查单接口的调用
1
2
3
4
5
6
@GetMapping("/query-refund/{refundNo}") 
public R queryRefund(@PathVariable String refundNo) throws Exception {
log.info("查询退款");
String result = wxPayService.queryRefund(refundNo);
return R.ok().setMessage("查询成功").data("result", result);
}

测试:

image-20220704195402523

4.11.2 定时查找退款中的订单

  • WxPayTask
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 从第0秒开始每隔30秒执行1次,查询创建超过5分钟,并且未成功的退款单
*/
@Scheduled(cron = "0/30 * * * * ?")
public void refundConfirm() throws Exception {
log.info("refundConfirm 被执行......");
//找出申请退款超过5分钟并且未成功的退款单
List<RefundInfo> refundInfoList = refundInfoService.getNoRefundOrderByDuration(5);
for (RefundInfo refundInfo : refundInfoList) {
String refundNo = refundInfo.getRefundNo();
log.warn("超时未退款的退款单号 ===> {}", refundNo);
//核实订单状态:调用微信支付查询退款接口
wxPayService.checkRefundStatus(refundNo);
}
}
  • RefundInfoService

接口:

1
2
3
4
5
6
/**
* 找出申请退款超过minutes分钟并且未成功的退款单
* @param minutes
* @return
*/
List<RefundInfo> getNoRefundOrderByDuration(int minutes);

实现:

1
2
3
4
5
6
7
8
9
10
@Override 
public List<RefundInfo> getNoRefundOrderByDuration(int minutes) {
//minutes分钟之前的时间
Instant instant = Instant.now().minus(Duration.ofMinutes(minutes));
QueryWrapper<RefundInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("refund_status", WxRefundStatus.PROCESSING.getType());
queryWrapper.le("create_time", instant);
List<RefundInfo> refundInfoList = baseMapper.selectList(queryWrapper);
return refundInfoList;
}

4.11.3 处理超时未退款订单

  • WxPayService

接口:

1
2
3
4
5
/**
* 根据退款单号核实退款单状态
* @param refundNo
*/
void checkRefundStatus(String refundNo);

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Transactional(rollbackFor = Exception.class) 
@Override
public void checkRefundStatus(String refundNo) throws Exception {
log.warn("根据退款单号核实退款单状态 ===> {}", refundNo);
//调用查询退款单接口
String result = this.queryRefund(refundNo);
//组装json请求体字符串
Gson gson = new Gson();
Map<String, String> resultMap = gson.fromJson(result, HashMap.class);
//获取微信支付端退款状态
String status = resultMap.get("status");
String orderNo = resultMap.get("out_trade_no");
if (WxRefundStatus.SUCCESS.getType().equals(status)) {
log.warn("核实订单已退款成功 ===> {}", refundNo);
//如果确认退款成功,则更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_SUCCESS); //更新退款单
refundsInfoService.updateRefund(result);
}
if (WxRefundStatus.ABNORMAL.getType().equals(status)) {
log.warn("核实订单退款异常 ===> {}", refundNo);
//如果确认退款成功,则更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_ABNORMAL);
//更新退款单
refundsInfoService.updateRefund(result);
}
}

4.12 退款结果通知API

文档:https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_4_11.shtml

思想同4.6节,为异步通知。

4.12.1 接收退款通知

  • WxPayController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@PostMapping("/refunds/notify") 
public String refundsNotify(HttpServletRequest request, HttpServletResponse response){
log.info("退款通知执行");
Gson gson = new Gson();
Map<String, String> map = new HashMap<>();
//应答对象
try {
//处理通知参数
String body = HttpUtils.readData(request);
Map<String, Object> bodyMap = gson.fromJson(body, HashMap.class);
String requestId = (String)bodyMap.get("id");
log.info("支付通知的id ===> {}", requestId);
//签名的验证
WechatPay2ValidatorForRequest wechatPay2ValidatorForRequest = new WechatPay2ValidatorForRequest(verifier, requestId, body);
if(!wechatPay2ValidatorForRequest.validate(request)){
log.error("通知验签失败");
//失败应答
response.setStatus(500);
map.put("code", "ERROR");
map.put("message", "通知验签失败");
return gson.toJson(map);
}
log.info("通知验签成功");
//处理退款单
wxPayService.processRefund(bodyMap);
//成功应答
response.setStatus(200);
map.put("code", "SUCCESS");
map.put("message", "成功");
return gson.toJson(map);
} catch (Exception e) {
e.printStackTrace();
//失败应答
response.setStatus(500);
map.put("code", "ERROR");
map.put("message", "失败"); return gson.toJson(map);
}
}

4.12.2 处理订单和退款单

WxPayService

接口:

1
2
3
4
5
6
/**
* 退款结果通知
* 退款状态改变后,微信会把相关退款结果发送给商户。
* @param bodyMap
*/
void processRefund(Map<String, Object> bodyMap) throws Exception;

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Transactional(rollbackFor = Exception.class) 
@Override
public void processRefund(Map<String, Object> bodyMap) throws Exception {
log.info("退款单");
//解密报文
String plainText = decryptFromResource(bodyMap);
//将明文转换成map
Gson gson = new Gson();
HashMap plainTextMap = gson.fromJson(plainText, HashMap.class);
String orderNo = (String)plainTextMap.get("out_trade_no");
if(lock.tryLock()){
try {
String orderStatus = orderInfoService.getOrderStatus(orderNo);
if (!OrderStatus.REFUND_PROCESSING.getType().equals(orderStatus)) {
return;
}
//更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_SUCCESS);
//更新退款单
refundsInfoService.updateRefund(plainText);
} finally {
//要主动释放锁
lock.unlock();
}
}
}

4.13 账单

4.13.1 申请交易账单和资金账单

  • WxPayController
1
2
3
4
5
6
@GetMapping("/querybill/{billDate}/{type}") 
public R queryTradeBill( @PathVariable String billDate, @PathVariable String type) throws Exception {
log.info("获取账单url");
String downloadUrl = wxPayService.queryBill(billDate, type);
return R.ok().setMessage("获取账单url成功").data("downloadUrl", downloadUrl);
}
  • WxPayService

接口:

1
2
3
4
5
6
7
/**
* 申请账单
* @param billDate
* @param type
* @return
*/
String queryBill(String billDate, String type) throws Exception;

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Override 
public String queryBill(String billDate, String type) throws Exception {
log.warn("申请账单接口调用 {}", billDate);
String url = "";
if("tradebill".equals(type)){
url = WxApiType.TRADE_BILLS.getType();
}else if("fundflowbill".equals(type)){
url = WxApiType.FUND_FLOW_BILLS.getType();
}else{
throw new RuntimeException("不支持的账单类型");
}
url = wxPayConfig.getDomain().concat(url).concat("? bill_date=").concat(billDate);
//创建远程Get 请求对象
HttpGet httpGet = new HttpGet(url);
httpGet.addHeader("Accept", "application/json");
//使用wxPayClient发送请求得到响应
CloseableHttpResponse response = wxPayClient.execute(httpGet);
try {
String bodyAsString = EntityUtils.toString(response.getEntity());
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 200) {
log.info("成功, 申请账单返回结果 = " + bodyAsString);
} else if (statusCode == 204) {
log.info("成功");
}
else {
throw new RuntimeException("申请账单异常, 响应码 = " + statusCode+ ", 申请账单返回结果 = " + bodyAsString);
}
//获取账单下载地址
Gson gson = new Gson();
Map<String, String> resultMap = gson.fromJson(bodyAsString, HashMap.class);
return resultMap.get("download_url");
} finally {
response.close();
}
}

4.13.2 下载账单

  • WxPayController
1
2
3
4
5
6
@GetMapping("/downloadbill/{billDate}/{type}")
public R downloadBill( @PathVariable String billDate, @PathVariable String type) throws Exception {
log.info("下载账单");
String result = wxPayService.downloadBill(billDate, type);
return R.ok().data("result", result);
}
  • WxPayService

接口:

1
2
3
4
5
6
7
/**
* 下载账单
* @param billDate
* @param type
* @return
*/
String downloadBill(String billDate, String type);

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override 
public String downloadBill(String billDate, String type) throws Exception {
log.warn("下载账单接口调用 {}, {}", billDate, type);
//获取账单url地址
String downloadUrl = this.queryBill(billDate, type);
//创建远程Get 请求对象
HttpGet httpGet = new HttpGet(downloadUrl);
httpGet.addHeader("Accept", "application/json");
//使用wxPayClient发送请求得到响应
CloseableHttpResponse response = wxPayNoSignClient.execute(httpGet);
try {
String bodyAsString = EntityUtils.toString(response.getEntity());
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 200) {
log.info("成功, 下载账单返回结果 = " + bodyAsString);
} else if (statusCode == 204) {
log.info("成功");
} else {
throw new RuntimeException("下载账单异常, 响应码 = " + statusCode+ ", 下载账单返回结果 = " + bodyAsString);
}
return bodyAsString;
} finally {
response.close();
}
}

5 Native支付API V2

image-20220704200844780

其余略