微信支付实战 学习时间: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
其余略