微信支付实战
学习时间:2022年5月20日
学习来源:尚硅谷
1 微信支付介绍和接入指引
1.1 微信支付产品介绍
- 付款码支付:用户展示微信钱包内的“付款码”给商家,商家扫描后直接完成支付,适用于线下面对面收银的场景。
- 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
算法
- 优点:运算速度快
- 缺点:秘钥需要信息交换的双方共享,一旦被窃取,消息会被破解,无法做到安全的密钥交换
- 非对称加密
- 特点:使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,常用的有
RSA
- 优点:黑客获取公钥无法破解密文,解决了密钥交换的问题
- 缺点:运算速度非常慢
2.3 身份认证
2.4 摘要算法
摘要算法就是我们常说的散列函数、哈希函数(Hash Function),它能够把任意长度的数据“压缩”成固定长度、而且独一无二的“摘要”字符串,就好像是给这段数据生成了一个数字“指纹”。
图示
- Bob将原文的内容生成摘要,附在原文后面。
- Pat接收到后,将原文按照相同算法生成摘要,与Bob附在原文后的摘要进行对比,若一致,则说明信息没有篡改。
- 问题:当黑客将Bob发给Pat数据的途中将其截获,同时将原来的摘要取出,篡改原文后重新生成摘要附在原文后,则当Pat接收到后进行第二步的验证,是无法确定原文是否被篡改。
2.5 数字签名
数字签名是使用私钥对摘要加密生成签名,需要由公钥将签名解密后进行验证,一起实现身份认证和信息加密两个作用。
该签名只能通过Bob的私钥将摘要加密才能生成,如果黑客不能获取Bob的私钥,是不能伪造签名的。
2.6 数字证书
数字证书解决“公钥的信任”问题,可以防止黑客伪造公钥。
不能直接分发公钥,公钥的分发必须使用数字证书,数字证书由CA
颁发。
上图,在实际场景中:Pat误以为自己是在和微信支付的服务器进行往来。
公钥的颁发
https中的数字证书
3 案例项目的创建
3.1 创建项目
注意: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
| <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"; } }
|
3.3 定义统一结果
作用:定义统一响应结果,为前端返回标准格式的数据。
1 2 3 4
| <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>
|
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 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() { 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
|
测试结果
3.4 创建数据库
一共有四张表:
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>
|
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
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;
private Long productId;
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配置文件
- 定义业务层:定义业务层接口继承
IService<>
,定义业务层接口的实现类,并继承 ServiceImpl<,>
- 定义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 } ] } }
|
1 2 3 4
| mybatis-plus: configuration: 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> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> <filtering>false</filtering> </resource> </resources> </build>
|
3.6 前端项目创建
过程略。
整个项目的架构:
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
|
wxpay.mch-id=
wxpay.mch-serial-no=
wxpay.private-key-path=
wxpay.api-v3-key=
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") @Data public class WxPayConfig {
private String mchId;
private String mchSerialNo;
private String privateKeyPath;
private String apiV3Key;
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); } }
|
测试结果:
补充——自定义配置文件
将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 -> 选择小叶子
- 点击(+) 图标
- 选中配置文件保存即可
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
|
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 证书秘钥使用说明
网址: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
|
@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
|
@Bean public CloseableHttpClient getWxPayClient(ScheduledUpdateCertificatesVerifier verifier) { PrivateKey privateKey = getPrivateKey(privateKeyPath); WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create() .withMerchant(mchId, mchSerialNo, privateKey) .withValidator(new WechatPay2Validator(verifier)); CloseableHttpClient httpClient = builder.build(); return httpClient; }
|
4.4 API字典和相关工具
https://pay.weixin.qq.com/wiki/doc/apiv3/open/pay/chapter2_7_3.shtml
指引文档——
项目要实现以下功能:
接口规则
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 目录复制到源码目录中。
为了开发方便,我们预先在项目中定义一些枚举。枚举中定义的内容包括接口地址,支付状态等信息。
代码示例
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
| @AllArgsConstructor @Getter public enum WxApiType {
NATIVE_PAY("/v3/pay/transactions/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 {
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
|
public class OrderNoUtils {
public static String getOrderNo() { return "ORDER_" + getNo(); }
public static String getRefundNo() { return "REFUND_" + getNo(); }
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
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 |
嵌套类型:
请求示例——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 {
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;
@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());
log.info("调用统一下单API"); HttpPost httpPost = new HttpPost(wxPayConfig.getDomain().concat(WxApiType.NATIVE_PAY.getType()));
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())); Map amountMap = new HashMap<>(); amountMap.put("total", orderInfo.getTotalFee()); amountMap.put("currency", "CNY"); paramsMap.put("amount", amountMap); 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 { String bodyAsString = EntityUtils.toString(response.getEntity()); int statusCode = response.getStatusLine().getStatusCode(); if (statusCode == 200) { log.info("success,return body = " + bodyAsString); } else if (statusCode == 204) { 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 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:
可在R类名上添加注解,使其支持链式调用:
1 2 3 4 5
| @Data @Accessors(chain = true) public class R { }
|
执行结果
打印信息:
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"}
|
前端展示:
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(); 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
| 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'
const service = axios.create({ baseURL: 'http://localhost:8090', timeout: 10000 })
)
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> {
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; }
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
|
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
|
@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
|
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); } }
|
前端逻辑略
执行结果
4.6 支付通知API
4.6.1 内网穿透配置
开发环境下通常处于内网或局域网(例如127.0.0.1
),微信服务器要向内网地址发送通知,此时我们需要进行内网穿透。项目上线时不需要此步,而是填写公网服务器的ip地址。
设置 authToken
为本地计算机做授权配置:
1
| ngrok authtoken 6aYc6Kp7kpxVr8pY88LkG_6x9o18yMY8BASrXiDFMeS
|
启动服务
指明协议和端口号(后端服务器的端口号,即接收微信服务器通知的端口号)
注意,每次重新启动后,获取的公网地址均不相同
将该地址填写入wxpay.properties
中
1 2 3
|
wxpay.notify-domain=https://7d92-115-171-63-135.ngrok.io
|
测试
4.6.2 接收通知和返回应答
① 接口说明
支付通知API:
https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_4_5.shtml
通知报文
支付结果通知是以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");
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) {
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秘钥加密和解密
的过程
参数解密
- 用商户平台上设置的APIv3密钥【微信商户平台—>账户设置—>API安全—>设置APIv3密钥】,记为key;
- 针对resource.algorithm中描述的算法(目前为
AEAD_AES_256_GCM
),取得对应的参数nonce和associated_data;
- 使用key、nonce和associated_data,对数据密文resource.ciphertext进行解密,得到JSON形式的资源对象;
解密工具
可直接使用SDK中的AesUtil进行解密
代码
- WxPayController:nativeNotify 方法中添加处理订单的代码
1 2
| wxPayService.processOrder(bodyMap);
|
接口:
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); }
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"); AesUtil aesUtil = new AesUtil(wxPayConfig.getApiV3Key().getBytes(StandardCharsets.UTF_8)); 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); 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
|
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
|
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
|
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();
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
|
@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("支付成功"); } return R.ok().setCode(101).setMessage("用户支付中...."); }
|
前端定时轮询查单
在二维码展示页面,前端定时轮询查询订单是否已支付(调用上述接口),如果支付成功则跳转到订单页面。
关键代码:
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: '', 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
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>
|
1 2 3 4 5 6 7 8
| export default{ queryOrderStatus(orderNo) { return request({ url: '/api/order-info/query-order-status/' + orderNo, method: 'get' }) } }
|
前端控制台打印结果
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); }
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); 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) { 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) { 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); }
|
测试:
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
实现任务调度
新建包task
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Component @Slf4j public class WxPayTask {
@Scheduled(cron = "0/3 * * * * ?") public void task1() { log.info("task1被执行..."); } }
|
4.9.4 定时查找超时订单
接口:
1 2 3 4 5 6
|
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); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13
|
@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 处理超时订单
接口:
1 2 3 4 5 6 7
|
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
| 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"}
[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"}
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 : 更新订单状态 ====> 超时已关闭
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 接口说明
当交易发生之后一年内,由于买家或者卖家的原因需要退款时,卖家可以通过退款接口将支付金额退还给买家,微信支付将在收到退款请求并且验证成功之后,将支付款按原路退还至买家账号上。
4.10.2 创建退款单
① 根据订单号查询订单
OrderInfoService
接口:
1 2 3 4 5 6
|
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
|
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
|
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) { 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); } if(resultMap.get("refund_status") != null){ refundInfo.setRefundStatus(resultMap.get("refund_status")); refundInfo.setContentNotify(content); } baseMapper.update(refundInfo, queryWrapper); }
|
4.10.4 申请退款
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(); }
|
接口:
1 2 3 4 5 6
|
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"); String url = wxPayConfig.getDomain().concat(WxApiType.DOMESTIC_REFUNDS.getType()); HttpPost httpPost = new HttpPost(url); 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); 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 查单接口调用
接口:
1 2 3 4 5 6
|
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); 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); }
|
测试:
4.11.2 定时查找退款中的订单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
@Scheduled(cron = "0/30 * * * * ?") public void refundConfirm() throws Exception { log.info("refundConfirm 被执行......"); List<RefundInfo> refundInfoList = refundInfoService.getNoRefundOrderByDuration(5); for (RefundInfo refundInfo : refundInfoList) { String refundNo = refundInfo.getRefundNo(); log.warn("超时未退款的退款单号 ===> {}", refundNo); wxPayService.checkRefundStatus(refundNo); } }
|
接口:
1 2 3 4 5 6
|
List<RefundInfo> getNoRefundOrderByDuration(int minutes);
|
实现:
1 2 3 4 5 6 7 8 9 10
| @Override public List<RefundInfo> getNoRefundOrderByDuration(int 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 处理超时未退款订单
接口:
1 2 3 4 5
|
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); 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 接收退款通知
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
|
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); 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 申请交易账单和资金账单
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); }
|
接口:
1 2 3 4 5 6 7
|
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); HttpGet httpGet = new HttpGet(url); httpGet.addHeader("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); } Gson gson = new Gson(); Map<String, String> resultMap = gson.fromJson(bodyAsString, HashMap.class); return resultMap.get("download_url"); } finally { response.close(); } }
|
4.13.2 下载账单
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); }
|
接口:
1 2 3 4 5 6 7
|
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); String downloadUrl = this.queryBill(billDate, type); HttpGet httpGet = new HttpGet(downloadUrl); httpGet.addHeader("Accept", "application/json"); 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
其余略