SpringBoot+Vue 微信支付

gitee仓库链接,前后端和辅助资料都上传了

wxpaydemo

1.微信支付产品介绍

微信支付主要包括付款码支付,JSAPI支付,小程序支付,Native支付,APP支付,刷脸支付等场景,本次主要学习Native支付,适用于pc网站,常见的微信扫一扫支付方式.

2.前置工作

(1)获取微信商户号

微信商户平台:https://pay.weixin.qq.com/

场景:Native支付 步骤:提交资料 => 签署协议 => 获取商户号

(2)在微信开放平台获取APPID

微信公众平台:https://mp.weixin.qq.com/

步骤:注册服务号 => 服务号认证 => 获取APPID => 绑定商户号

(3)获取API密钥

APIv2版本的接口需要此秘钥 步骤:登录商户平台 => 选择 账户中心 => 安全中心 => API安全 => 设置API密钥

(4)获取APIV3密钥

APIv3版本的接口需要此秘钥

步骤:登录商户平台 => 选择 账户中心 => 安全中心 => API安全 => 设置APIv3密钥 随机密码生成工具:https://suijimimashengcheng.bmcx.com/

(5)申请商户API证书

APIv3版本的所有接口都需要

APIv2版本的高级接口需要(如:退款、企业红包、企业付款等)

步骤:登录商户平台 => 选择 账户中心 => 安全中心 => API安全 => 申请API证书

(6)获取微信怕平台证书

可以预先下载,也可以通过编程的方式获取。后面的课程中,我们会通过编程的方式来获取。

3.创建Springboot微信支付案例

(1)创建sprigboot项目

(2)添加依赖

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>

(3)创建application.yml配置文件

server:port: 8090
spring:application:name: payment-demo

(4)创建controller包

创建controller包,创建ProductController类

@Api(tags = "微信支付")
@RestController
@RequestMapping("/api/product")
@CrossOrigin //跨域
public class ProductController {@ApiOperation(value = "test")@GetMapping("/test")public String test() {return "hello";}
}

(5)引入swagger3依赖

        <!--swagger3依赖--><dependency><groupId>io.springfox</groupId><artifactId>springfox-boot-starter</artifactId><version>3.0.0</version></dependency><!--美化ui--><dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-spring-ui</artifactId><version>3.0.3</version></dependency>

(6)swagger3配置类

@Configuration
@EnableOpenApi
//@EnableSwaggerBootstrapUI
public class SwaggerConfig {@Value("${swagger.enabled}")private boolean enable;/*** 创建API* http:IP:端口号/swagger-ui/index.html 原生地址* http:IP:端口号/doc.html bootStrap-UI地址*/@Beanpublic Docket createRestApi() {return new Docket(DocumentationType.OAS_30).pathMapping("/")// 用来创建该API的基本信息,展示在文档的页面中(自定义展示的信息)/*.enable(enable)*/.apiInfo(apiInfo())// 设置哪些接口暴露给Swagger展示.select()// 扫描所有有注解的api,用这种方式更灵活.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))// 扫描指定包中的swagger注解.apis(RequestHandlerSelectors.basePackage("com.acerola.paymentdemo.controller"))// 扫描所有 .apis(RequestHandlerSelectors.any()).paths(PathSelectors.regex("(?!/ApiError.*).*")).paths(PathSelectors.any()).build()// 支持的通讯协议集合.protocols(newHashSet("https", "http")).securitySchemes(securitySchemes()).securityContexts(securityContexts());}/*** 支持的通讯协议集合* @param type1* @param type2* @return*/private Set<String> newHashSet(String type1, String type2){Set<String> set = new HashSet<>();set.add(type1);set.add(type2);return set;}/*** 认证的安全上下文*/private List<SecurityScheme> securitySchemes() {List<SecurityScheme> securitySchemes = new ArrayList<>();securitySchemes.add((SecurityScheme) new ApiKey("token", "token", "header"));return securitySchemes;}/*** 授权信息全局应用*/private List<SecurityContext> securityContexts() {List<SecurityContext> securityContexts = new ArrayList<>();securityContexts.add(SecurityContext.builder().securityReferences(defaultAuth()).forPaths(PathSelectors.any()).build());return securityContexts;}private List<SecurityReference> defaultAuth() {AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];authorizationScopes[0] = authorizationScope;List<SecurityReference> securityReferences = new ArrayList<>();securityReferences.add(new SecurityReference("Authorization", authorizationScopes));return securityReferences;}/*** 添加摘要信息*/private ApiInfo apiInfo() {// 用ApiInfoBuilder进行定制return new ApiInfoBuilder()// 设置标题.title("微信支付")// 描述.description("微信支付")// 作者信息.contact(new Contact("doctorCloud", null, null))// 版本.version("版本号:V.1")//协议.license("The Apache License")//协议url.licenseUrl("http://www.baidu.com").build();}
}

(7)测试swagger文档

http://localhost:8090/doc.html#/

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-noQdPbG7-1648467247291)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220326104137315.png)]

(8)引入lombok,简化实体开发

        <dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>

(9)创建R类,统一返回结果

@Data //生成set、get等方法
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;}
}

(10)配置json时间格式

spring:jackson: #json时间格式date-format: yyyy-MM-dd HH:mm:sstime-zone: GMT+8

(11)创建mysql数据库

创建payment_demo数据库,并执行以下sql

USE `payment_demo`;/*Table structure for table `t_order_info` */CREATE TABLE `t_order_info` (`id` bigint(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '订单id',`title` varchar(256) DEFAULT NULL COMMENT '订单标题',`order_no` varchar(50) DEFAULT NULL COMMENT '商户订单编号',`user_id` bigint(20) DEFAULT NULL COMMENT '用户id',`product_id` bigint(20) DEFAULT NULL COMMENT '支付产品id',`total_fee` int(11) DEFAULT NULL COMMENT '订单金额(分)',`code_url` varchar(50) DEFAULT NULL COMMENT '订单二维码连接',`order_status` varchar(10) DEFAULT NULL COMMENT '订单状态',`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;/*Table structure for table `t_payment_info` */CREATE TABLE `t_payment_info` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '支付记录id',`order_no` varchar(50) DEFAULT NULL COMMENT '商户订单编号',`transaction_id` varchar(50) DEFAULT NULL COMMENT '支付系统交易编号',`payment_type` varchar(20) DEFAULT NULL COMMENT '支付类型',`trade_type` varchar(20) DEFAULT NULL COMMENT '交易类型',`trade_state` varchar(50) DEFAULT NULL COMMENT '交易状态',`payer_total` int(11) DEFAULT NULL COMMENT '支付金额(分)',`content` text COMMENT '通知参数',`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;/*Table structure for table `t_product` */CREATE TABLE `t_product` (`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品id',`title` varchar(20) DEFAULT NULL COMMENT '商品名称',`price` int(11) DEFAULT NULL COMMENT '价格(分)',`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;/*Data for the table `t_product` */insert  into `t_product`(`title`,`price`) values ('Java课程',1);
insert  into `t_product`(`title`,`price`) values ('大数据课程',1);
insert  into `t_product`(`title`,`price`) values ('前端课程',1);
insert  into `t_product`(`title`,`price`) values ('UI课程',1);/*Table structure for table `t_refund_info` */CREATE TABLE `t_refund_info` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '退款单id',`order_no` varchar(50) DEFAULT NULL COMMENT '商户订单编号',`refund_no` varchar(50) DEFAULT NULL COMMENT '商户退款单编号',`refund_id` varchar(50) DEFAULT NULL COMMENT '支付系统退款单号',`total_fee` int(11) DEFAULT NULL COMMENT '原订单金额(分)',`refund` int(11) DEFAULT NULL COMMENT '退款金额(分)',`reason` varchar(50) DEFAULT NULL COMMENT '退款原因',`refund_status` varchar(10) DEFAULT NULL COMMENT '退款状态',`content_return` text COMMENT '申请退款返回参数',`content_notify` text COMMENT '退款结果通知参数',`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

(12) 集成mybatis-plus

引入依赖

<!--mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--持久层-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.1</version>
</dependency>

(13) 配置数据库连接

datasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/payment_demo?serverTimezone=GMT%2B8&characterEncoding=utf-8username: rootpassword: 123456

(14)定义实体类

BaseEntity:

@Data
public class BaseEntity {//定义主键策略:跟随数据库的主键自增@TableId(value = "id", type = IdType.AUTO)private String id; //主键private Date createTime;//创建时间private Date updateTime;//更新时间
}

OrderInfo:

@Data
@TableName("t_order_info")
public class OrderInfo  extends BaseEntity{private String title;//订单标题private String orderNo;//商户订单编号private Long userId;//用户idprivate Long productId;//支付产品idprivate Integer totalFee;//订单金额(分)private String codeUrl;//订单二维码连接private String orderStatus;//订单状态
}

PaymentInfo:

@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;//通知参数
}

Product:

@Data
@TableName("t_product")
public class Product extends BaseEntity{private String title; //商品名称private Integer price; //价格(分)
}

RefundInfo:

@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;//退款结果通知参数
}

(15)定义持久层

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iISHg9Ga-1648467247295)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220326150017853.png)]

(16)定义mybatis-plus的yml文件配置

mybatis-plus:configuration:log-impl: org.apache.ibatis.logging.stdout.StdOutImplmapper-locations:- classpath: src/main/resources/mapper/*.xml

(17) 定义业务层

定义业务层接口继承 IService<>

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O9NP08o5-1648467247297)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220326150316641.png)]

(18) 查询所有商品测试

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

swagger测试:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b6b9wTzl-1648467247298)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220326151342914.png)]

4. 搭建前端环境

此处为vue基础,略过

5. 基础支付API V3

(1) 引入支付参数

1.1定义微信支付相关参数

将资料文件夹中的 wxpay.properties 复制到resources目录中 这个文件定义了之前我们准备的微信支付相关的参数,例如商户号、APPID、API秘钥等等

wxpay.properties:

# 微信支付相关参数
# 商户号
wxpay.mch-id=1558950191
# 商户API证书序列号
wxpay.mch-serial-no=34345964330B66427E0D3D28826C4993C77E631F
# 商户私钥文件
wxpay.private-key-path=apiclient_key.pem
# APIv3密钥
wxpay.api-v3-key=UDuLFDcmy5Eb6o0nTNZdu6ek4DDh4K8B
# APPID
wxpay.appid=wx74862e0dfcf69954
# 微信服务器地址
wxpay.domain=https://api.mch.weixin.qq.com
# 接收结果通知地址
wxpay.notify-domain=https://7d92-115-171-63-135.ngrok.io

1.2 读取支付参数

WxPayConfig.java:

@Configuration
@PropertySource("classpath:wxpay.properties") //读取配置文件
@ConfigurationProperties(prefix="wxpay") //读取wxpay节点
@Data //使用set方法将wxpay节点中的值填充到当前类的属性中
public class WxPayConfig {// 商户号private String mchId;// 商户API证书序列号private String mchSerialNo;// 商户私钥文件private String privateKeyPath;// APIv3密钥private String apiV3Key;// APPIDprivate String appid;// 微信服务器地址private String domain;// 接收结果通知地址private String notifyDomain;}

1.3 测试支付参数的获取

在controller包建立TestController类

@Api(tags = "测试控制器")
@RestController
@RequestMapping("/api/test")
public class TestController {@Autowiredprivate WxPayConfig wxPayConfig;@ApiOperation(value = "测试支付参数的获取")@GetMapping("/get-wx-pay-config")public R getWxPayConfig() {String mchId = wxPayConfig.getMchId();return R.ok().data("mchId", mchId);}
}

1.4 配置 Annotation Processor

可以帮助我们生成自定义配置的元数据信息,让配置文件和Java代码之间的对应参数可以自动定位,方便开发。

  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-configuration-processor</artifactId><optional>true</optional></dependency>

1.5 设置wxpay.properties为springboot配置文件

让IDEA可以识别配置文件,将配置文件的图标展示成SpringBoot的图标,同时配置文件的内容可以高亮显示

File -> Project Structure -> Modules -> 选择小叶子

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-q2UbX2qu-1648467247299)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220326163120508.png)]

(2) 加载商户私钥

2.1复制商户私钥

2.2 引入SDK

        <dependency><groupId>com.github.wechatpay-apiv3</groupId><artifactId>wechatpay-apache-httpclient</artifactId><version>0.3.0</version></dependency>

2.3测试商户私钥的获取

在WxPayConfig.java中添加获取商户私钥文件的方法

    /*** 获取商户私钥文件** @param filename* @return* @throws FileNotFoundException*/public PrivateKey getPrivateKey(String filename){try {return PemUtil.loadPrivateKey(new FileInputStream(filename));} catch (FileNotFoundException e) {throw new RuntimeException("私钥不存在",e);}}

在 PaymentDemoApplicationTests 测试类中添加如下方法,测试私钥对象是否能够获取出来。

   @Testpublic void testGetPrivateKey() {//获取私钥路径String privateKeyPath = wxPayConfig.getPrivateKeyPath();//获取商户私钥PrivateKey privateKey = wxPayConfig.getPrivateKey(privateKeyPath);System.out.println(privateKey);}

(3)获取签名验证器和HttpClient

3.1 证书密钥使用说明

3.2 获取签名验证器

https://github.com/wechatpay-apiv3/wechatpay-apache-httpclient (定时更新平台证书功能) 平台证书:平台证书封装了微信的公钥,商户可以使用平台证书中的公钥进行验签。 签名验证器:帮助我们进行验签工作,我们单独将它定义出来,方便后面的开发。

@Beanpublic ScheduledUpdateCertificatesVerifier getVerifier() {//获取商户私钥PrivateKey privateKey = getPrivateKey(privateKeyPath);
//私钥签名对象(签名)PrivateKeySigner privateKeySigner = new PrivateKeySigner(mchSerialNo,privateKey);
//身份认证对象(验签)WechatPay2Credentials wechatPay2Credentials = newWechatPay2Credentials(mchId, privateKeySigner);
// 使用定时更新的签名验证器,不需要传入证书ScheduledUpdateCertificatesVerifier verifier = newScheduledUpdateCertificatesVerifier(wechatPay2Credentials,apiV3Key.getBytes(StandardCharsets.UTF_8));return verifier;}

3.3 获取 HttpClient 对象

https://github.com/wechatpay-apiv3/wechatpay-apache-httpclient (定时更新平台证书功能) HttpClient 对象:是建立远程连接的基础,我们通过SDK创建这个对象。

 /*** 获取HttpClient对象** @param verifier* @return*/@Beanpublic CloseableHttpClient getWxPayClient(ScheduledUpdateCertificatesVerifierverifier) {//获取商户私钥PrivateKey privateKey = getPrivateKey(privateKeyPath);//用于构造HttpClientWechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create().withMerchant(mchId, mchSerialNo, privateKey).withValidator(new WechatPay2Validator(verifier));// ... 接下来,你仍然可以通过builder设置各种参数,来配置你的HttpClient// 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签,并进行证书自动更新CloseableHttpClient httpClient = builder.build();return httpClient;}

(4)API字典和相关工具

4.1 API列表

模块名称 功能列表 描述
Native支付 Native下单 通过本接口提交微信支付Native支付订单
查询订单 通过此接口查询订单状态
关闭订单 通过此接口关闭待支付订单
Native调起支付 商户后台系统先调用微信支付的Native支付接口,微信后台系统返回链接参数code_url,商户后台系统将code_url值生成二维码图片,用户使用微信客户端扫码后发起支付。
支付结果通知 微信支付通过支付通知接口将用户支付成功消息通知给商户
申请退款 商户可以通过该接口将支付金额退还给买家
查询单笔退款 提交退款申请后,通过调用该接口查询退款状态
退款结果通知 微信支付通过退款通知接口将用户退款成功消息通知给商户
申请交易账单 商户可以通过该接口获取交易账单文件的下载地址
申请资金账单 商户可以通过该接口获取资金账单文件的下载地址
下载账单 通过申请交易/资金账单获取到download_url在该接口获取到对应的账单。

4.2 接口规则

https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay2_0.shtml 微信支付 APIv3 使用 JSON 作为消息体的数据交换格式。

我们使用谷歌的json处理

<!--json处理-->
<dependency><groupId>com.google.code.gson</groupId><artifactId>gson</artifactId>
</dependency>

4.3定义枚举

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ybh6aBlk-1648467247302)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220326174737976.png)]

4.4添加工具类

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H0L2h6Zk-1648467247303)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220326174822867.png)]

(5)Native下单API

5.1Native下单流程

微信支付-开发者文档 (qq.com)

5.2 Native下单API

微信支付-开发者文档 (qq.com)

5.2.1 创建WxPayController
package com.acerola.paymentdemo.controller;import io.swagger.annotations.Api;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;/*** @program: payment-demo* @description:* @author: Acerola* @create: 2022-03-26 17:53**/
@CrossOrigin
@RestController
@RequestMapping("/api/wx-pay")
@Api(tags = "网站微信支付")
@Slf4j
public class WxPayController {}
5.2.2 创建service层

接口:

package com.acerola.paymentdemo.service;public interface WxPayService {}

实现:

package com.acerola.paymentdemo.service.impl;import com.acerola.paymentdemo.service.WxPayService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;/*** @program: payment-demo* @description:* @author: Acerola* @create: 2022-03-26 17:55**/
@Service
@Slf4j
public class WxPayServiceImpl implements WxPayService {}
5.2.3 定义WxPayController方法

R对象中添加 @Accessors(chain = true),使其可以链式操作

    /*** Native下单** @param productId* @return* @throws Exception*/@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);}
5.2.4 定义WxPayService方法

实现

@Autowiredprivate WxPayConfig wxPayConfig;@Autowiredprivate CloseableHttpClient wxPayClient;/*** 创建订单,调用Native支付接口** @param productId* @return code_url 和 订单号*/@SneakyThrows@Overridepublic Map<String, Object> nativePay(Long productId) {log.info("生成订单");//生成订单OrderInfo orderInfo = new OrderInfo();orderInfo.setTitle("test");orderInfo.setOrderNo(OrderNoUtils.getOrderNo()); //订单号orderInfo.setProductId(productId);orderInfo.setTotalFee(1); //分orderInfo.setOrderStatus(OrderStatus.NOTPAY.getType());//TODO:存入数据库log.info("调用统一下单API");//调用统一下单APIHttpPost httpPost = newHttpPost(wxPayConfig.getDomain().concat(WxApiType.NATIVE_PAY.getType()));// 请求body参数Gson gson = new Gson();Map paramsMap = new HashMap();paramsMap.put("appid", wxPayConfig.getAppid());paramsMap.put("mchid", wxPayConfig.getMchId());paramsMap.put("description", orderInfo.getTitle());paramsMap.put("out_trade_no", orderInfo.getOrderNo());paramsMap.put("notify_url",wxPayConfig.getNotifyDomain().concat(WxNotifyType.NATIVE_NOTIFY.getType()));Map amountMap = new HashMap();amountMap.put("total", orderInfo.getTotalFee());amountMap.put("currency", "CNY");paramsMap.put("amount", amountMap);//将参数转换成json字符串String jsonParams = gson.toJson(paramsMap);log.info("请求参数:" + jsonParams);StringEntity entity = new StringEntity(jsonParams, "utf-8");entity.setContentType("application/json");httpPost.setEntity(entity);httpPost.setHeader("Accept", "application/json");//完成签名并执行请求CloseableHttpResponse response = wxPayClient.execute(httpPost);try {String bodyAsString = EntityUtils.toString(response.getEntity());//响应体int statusCode = response.getStatusLine().getStatusCode();//响应状态码if (statusCode == 200) { //处理成功log.info("成功, 返回结果 = " + bodyAsString);} else if (statusCode == 204) { //处理成功,无返回Bodylog.info("成功");} else {log.info("Native下单失败,响应码 = " + statusCode + ",返回结果 = " +bodyAsString);throw new IOException("request failed");}//响应结果Map<String, String> resultMap = gson.fromJson(bodyAsString,HashMap.class);//二维码String codeUrl = resultMap.get("code_url");Map<String, Object> map = new HashMap<>();map.put("codeUrl", codeUrl);map.put("orderNo", orderInfo.getOrderNo());return map;} finally {response.close();}}
5.2.5 测试调用统一下单API,生成支付二维码

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BDPFpSkB-1648467247306)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220326181237729.png)]

5.3 创建课程订单

5.3.1 保存订单

OrderInfoService

接口:

 OrderInfo createOrderByProductId(Long productId);

实现:

  @Resourceprivate ProductMapper productMapper;@Overridepublic OrderInfo createOrderByProductId(Long productId) {//查找已存在但未支付的订单OrderInfo orderInfo = this.getNoPayOrderByProductId(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());baseMapper.insert(orderInfo);return orderInfo;}

查找未支付订单:OrderInfoService中添加辅助方法

 /*** 根据商品id查询未支付订单* 防止重复创建订单对象** @param productId* @return*/private OrderInfo getNoPayOrderByProductId(Long productId) {QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();queryWrapper.eq("product_id", productId);queryWrapper.eq("order_status", OrderStatus.NOTPAY.getType());// queryWrapper.eq("user_id", userId);OrderInfo orderInfo = baseMapper.selectOne(queryWrapper);return orderInfo;}
5.3.2 缓存二维码

OrderInfoService

接口:

OrderInfo createOrderByProductId(Long productId);

实现:

    /*** 存储订单二维码* @param orderNo* @param codeUrl*/@Overridepublic void saveCodeUrl(String orderNo, String codeUrl) {QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();queryWrapper.eq("order_no", orderNo);OrderInfo orderInfo = new OrderInfo();orderInfo.setCodeUrl(codeUrl);baseMapper.update(orderInfo, queryWrapper);}
5.3.3 修改WxPayServiceImpl 的 nativePay 方法
 @Autowiredprivate OrderInfoService orderInfoService;/*** 创建订单,调用Native支付接口** @param productId* @return code_url 和 订单号*/@SneakyThrows@Overridepublic Map<String, Object> nativePay(Long productId)  {log.info("生成订单");//生成订单OrderInfo orderInfo = orderInfoService.createOrderByProductId(productId);String codeUrl = orderInfo.getCodeUrl();if(orderInfo != null && !StringUtils.isEmpty(codeUrl)){log.info("订单已存在,二维码已保存");//返回二维码Map<String, Object> map = new HashMap<>();map.put("codeUrl", codeUrl);map.put("orderNo", orderInfo.getOrderNo());return map;}log.info("调用统一下单API");//调用统一下单APIHttpPost httpPost = new HttpPost(wxPayConfig.getDomain().concat(WxApiType.NATIVE_PAY.getType()));// 请求body参数Gson gson = new Gson();Map paramsMap = new HashMap();paramsMap.put("appid", wxPayConfig.getAppid());paramsMap.put("mchid", wxPayConfig.getMchId());paramsMap.put("description", orderInfo.getTitle());paramsMap.put("out_trade_no", orderInfo.getOrderNo());paramsMap.put("notify_url", wxPayConfig.getNotifyDomain().concat(WxNotifyType.NATIVE_NOTIFY.getType()));Map amountMap = new HashMap();amountMap.put("total", orderInfo.getTotalFee());amountMap.put("currency", "CNY");paramsMap.put("amount", amountMap);//将参数转换成json字符串String jsonParams = gson.toJson(paramsMap);log.info("请求参数 ===> {}" + jsonParams);StringEntity entity = new StringEntity(jsonParams,"utf-8");entity.setContentType("application/json");httpPost.setEntity(entity);httpPost.setHeader("Accept", "application/json");//完成签名并执行请求CloseableHttpResponse response = wxPayClient.execute(httpPost);try {String bodyAsString = EntityUtils.toString(response.getEntity());//响应体int statusCode = response.getStatusLine().getStatusCode();//响应状态码if (statusCode == 200) { //处理成功log.info("成功, 返回结果 = " + bodyAsString);} else if (statusCode == 204) { //处理成功,无返回Bodylog.info("成功");} else {log.info("Native下单失败,响应码 = " + statusCode+ ",返回结果 = " + bodyAsString);throw new IOException("request failed");}//响应结果Map<String, String> resultMap = gson.fromJson(bodyAsString, HashMap.class);//二维码codeUrl = resultMap.get("code_url");//保存二维码String orderNo = orderInfo.getOrderNo();orderInfoService.saveCodeUrl(orderNo, codeUrl);//返回二维码Map<String, Object> map = new HashMap<>();map.put("codeUrl", codeUrl);map.put("orderNo", orderInfo.getOrderNo());return map;} finally {response.close();}}

5.4 显示订单列表

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

5.4.1 创建OrderInfoController
package com.acerola.paymentdemo.controller;import com.acerola.paymentdemo.common.R;
import com.acerola.paymentdemo.entity.OrderInfo;
import com.acerola.paymentdemo.service.OrderInfoService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import java.util.List;/*** @program: payment-demo* @description:* @author: Acerola* @create: 2022-03-27 17:57**/@CrossOrigin //开放前端的跨域访问
@Api(tags = "商品订单管理")
@RestController
@RequestMapping("/api/order-info")
public class OrderInfoController {@Autowiredprivate OrderInfoService orderInfoService;@ApiOperation("订单列表")@GetMapping("/list")public R list() {List<OrderInfo> list = orderInfoService.listOrderByCreateTimeDesc();return R.ok().data("list", list);}
}
5.4.2 定义 OrderInfoService 方法

接口:

List<OrderInfo> listOrderByCreateTimeDesc();

实现:

    /*** 查询订单列表,并倒序查询* @return*/@Overridepublic List<OrderInfo> listOrderByCreateTimeDesc() {QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<OrderInfo>().orderByDesc("create_time");return baseMapper.selectList(queryWrapper);}

(6)支付通知API

6.1 内网穿透

6.1.1下载内网穿透工具

下载ngrok

6.1.2 设置authtoken
ngrok authtoken 26y23wgoy8AEsyn4CC9qfLbEvK0_2A4okRhSWNU6GprLQjyHZ
6.1.3 启动内网穿透
ngrok http 8090

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mHnBKosO-1648467247307)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220327201945354.png)]

6.1.4 测试外网访问
http://922c-117-158-127-32.ngrok.io/api/order-info/list

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3aEg2FgA-1648467247308)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220327232105872.png)]

6.2 接收通知和返回应答

支付通知API:https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_4_5.shtml

6.2.1启动ngrok
ngrok http 8090
6.2.2设置通知地址

wxpay.properties

注意:每次重新启动ngrok,都需要根据实际情况修改这个配置

wxpay.notify-domain=https://7d92-115-171-63-135.ngrok.io
6.2.3创建通知接口

通知规则:用户支付完成后,微信会把相关支付结果和用户信息发送给商户,商户需要接收处理 该消息,并返回应答。对后台通知交互时,如果微信收到商户的应答不符合规范或超时,微信认 为通知失败,微信会通过一定的策略定期重新发起通知,尽可能提高通知的成功率,但微信不保 证通知最终能成功。(通知频率为 15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h - 总计 24h4m)

 /*** 支付通知* 微信支付通过支付通知接口将用户支付成功消息通知给商户*/@ApiOperation("支付通知")@PostMapping("/native/notify")public String nativeNotify(HttpServletRequest request, HttpServletResponseresponse) {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);//TODO : 签名的验证//TODO : 处理订单//成功应答:成功应答必须为200或204,否则就是失败应答response.setStatus(200);map.put("code", "SUCCESS");map.put("message", "成功");return gson.toJson(map);}
6.2.4测试失败应答

用失败应答替换成功应答

 @PostMapping("/native/notify")public String nativeNotify(HttpServletRequest request, HttpServletResponseresponse) throws Exception {Gson gson = new Gson();Map<String, String> map = new HashMap<>();try {} catch (Exception e) {e.printStackTrace();// 测试错误应答response.setStatus(500);map.put("code", "ERROR");map.put("message", "系统错误");return gson.toJson(map);}}

6.3验签

6.3.1 工具类

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

6.3.2 验签
@Resource
private Verifier verifier;

更新WxPayController中nativeNotify方法

 /*** 支付通知* 微信支付通过支付通知接口将用户支付成功消息通知给商户*/@ApiOperation("支付通知")@PostMapping("/native/notify")public String nativeNotify(HttpServletRequest request, HttpServletResponseresponse) throws IOException {Gson gson = new Gson();Map<String, String> map = new HashMap<>();//应答对象//处理通知参数String body = HttpUtils.readData(request);Map<String, Object> bodyMap = gson.fromJson(body, HashMap.class);String requestId = (String) bodyMap.get("id");log.info("支付通知的id ===> {}", bodyMap.get("id"));log.info("支付通知的完整数据 ===> {}", body);//TODO : 签名的验证//签名的验证WechatPay2ValidatorForRequest validator= new WechatPay2ValidatorForRequest(verifier, body, requestId);if (!validator.validate(request)) {log.error("通知验签失败");//失败应答response.setStatus(500);map.put("code", "ERROR");map.put("message", "通知验签失败");return gson.toJson(map);}log.info("通知验签成功");//TODO : 处理订单//成功应答:成功应答必须为200或204,否则就是失败应答response.setStatus(200);map.put("code", "SUCCESS");map.put("message", "成功");return gson.toJson(map);}

6.4解密

6.4.1 WxPayController

nativeNotify 方法中添加处理订单的代码

//处理订单
wxPayService.processOrder(bodyMap);
6.4.2 WxPayService

接口:

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

实现:

/*** 对称解密** @param bodyMap* @return*/private String decryptFromResource(Map<String, Object> bodyMap) throwsGeneralSecurityException {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");log.info("密文 ===> {}", ciphertext);AesUtil aesUtil = newAesUtil(wxPayConfig.getApiV3Key().getBytes(StandardCharsets.UTF_8));String plainText =aesUtil.decryptToString(associatedData.getBytes(StandardCharsets.UTF_8),nonce.getBytes(StandardCharsets.UTF_8),ciphertext);log.info("明文 ===> {}", plainText);return plainText;}

6.5处理订单

6.5.1 完善processOrder方法
 @Autowiredprivate PaymentInfoService paymentInfoService;@Overridepublic void processOrder(Map<String, Object> bodyMap) throwsGeneralSecurityException {log.info("处理订单");String plainText = decryptFromResource(bodyMap);//转换明文Gson gson = new Gson();Map<String, Object> plainTextMap = gson.fromJson(plainText, HashMap.class);String orderNo = (String)plainTextMap.get("out_trade_no");//更新订单状态orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);//记录支付日志paymentInfoService.createPaymentInfo(plainText);}
6.5.2更新订单状态

OrderInfoService

接口:

void updateStatusByOrderNo(String orderNo, OrderStatus orderStatus);

实现:

/*** 根据订单编号更新订单状态** @param orderNo* @param orderStatus*/@Overridepublic 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());baseMapper.update(orderInfo, queryWrapper);}
6.5.3处理支付日志

PaymentInfoService

接口:

void createPaymentInfo(String plainText);

实现:

/*** 记录支付日志** @param plainText*/@Overridepublic void createPaymentInfo(String plainText) {log.info("记录支付日志");Gson gson = new Gson();Map<String, Object> plainTextMap = gson.fromJson(plainText, HashMap.class);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);paymentInfo.setContent(plainText);baseMapper.insert(paymentInfo);}

6.6处理重复通知

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JIRw5qzC-1648467247310)(C:\Users\Acerola\AppData\Roaming\Typora\typora-user-images\image-20220328104343532.png)]

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

OrderInfoService

接口:

String getOrderStatus(String orderNo);

实现:

 /*** 根据订单号获取订单状态** @param orderNo* @return*/@Overridepublic String getOrderStatus(String orderNo) {QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();queryWrapper.eq("order_no", orderNo);OrderInfo orderInfo = baseMapper.selectOne(queryWrapper);//防止被删除的订单的回调通知的调用if (orderInfo == null) {return null;}return orderInfo.getOrderStatus();}

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

    //处理重复通知//保证接口调用的幂等性:无论接口被调用多少次,产生的结果是一致的String orderStatus = orderInfoService.getOrderStatus(orderNo);if (!OrderStatus.NOTPAY.getType().equals(orderStatus)) {return;}

6.7数据锁

6.7.1定义ReentrantLock

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

private final ReentrantLock lock = new ReentrantLock();
  @Overridepublic void processOrder(Map<String, Object> bodyMap) throwsGeneralSecurityException {log.info("处理订单");//解密报文String plainText = decryptFromResource(bodyMap);//将明文转换成mapGson gson = new Gson();HashMap plainTextMap = gson.fromJson(plainText, HashMap.class);String orderNo = (String) plainTextMap.get("out_trade_no");/*在对业务数据进行状态检查和处理之前,要采用数据锁进行并发控制,以避免函数重入造成的数据混乱*///尝试获取锁:// 成功获取则立即返回true,获取失败则立即返回false。不必一直等待锁的释放if (lock.tryLock()) {try {//处理重复的通知//接口调用的幂等性:无论接口被调用多少次,产生的结果是一致的。String orderStatus = orderInfoService.getOrderStatus(orderNo);if (!OrderStatus.NOTPAY.getType().equals(orderStatus)) {return;}//模拟通知并发try {TimeUnit.SECONDS.sleep(5);} catch (InterruptedException e) {e.printStackTrace();}//更新订单状态orderInfoService.updateStatusByOrderNo(orderNo,OrderStatus.SUCCESS);//记录支付日志paymentInfoService.createPaymentInfo(plainText);} finally {//要主动释放锁lock.unlock();}}}

(7)商户定时查询本地订单

7.1 后端定义商户查单接口

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

 /*** 查询本地订单状态*/@ApiOperation("查询本地订单状态")@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();}return R.ok().setCode(101).setMessage("支付中...");}

7.2 前端定时轮询查单

在二维码展示页面,前端定时轮询查询订单是否已支付,如果支付成功则跳转到订单页面

7.2.1定义定时器
//启动定时器
this.timer = setInterval(() => {
//查询订单是否支付成功
this.queryOrderStatus()
}, 3000)
7.2.2 查询订单
// 查询订单状态
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: '/success' })
}, 3000)
}
})
}

(8)用户取消订单API

实现用户主动取消订单的功能

8.1定义取消订单接口

WxPayController中添加接口方法

 /*** 用户取消订单** @param orderNo* @return* @throws Exception*/@ApiOperation("用户取消订单")@PostMapping("/cancel/{orderNo}")public R cancel(@PathVariable String orderNo) throws Exception {log.info("取消订单");wxPayService.cancelOrder(orderNo);return R.ok().setMessage("订单已取消");}

8.2WxPayService

接口:

void cancelOrder(String orderNo) throws Exception;

实现:

    /*** 用户取消订单** @param orderNo*/@Overridepublic void cancelOrder(String orderNo) throws Exception {//调用微信支付的关单接口this.closeOrder(orderNo);//更新商户端的订单状态orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.CANCEL);}

关单方法

/*** 关单接口的调用** @param orderNo*/private void closeOrder(String orderNo) throws Exception {log.info("关单接口的调用,订单号 ===> {}", orderNo);//创建远程请求对象String url = String.format(WxApiType.CLOSE_ORDER_BY_NO.getType(), orderNo);url = wxPayConfig.getDomain().concat(url);HttpPost httpPost = new HttpPost(url);//组装json请求体Gson gson = new Gson();Map<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 = wxPayClient.execute(httpPost);try {int statusCode = response.getStatusLine().getStatusCode();//响应状态码if (statusCode == 200) { //处理成功log.info("成功200");} else if (statusCode == 204) { //处理成功,无返回Bodylog.info("成功204");} else {log.info("Native下单失败,响应码 = " + statusCode);throw new IOException("request failed");}} finally {response.close();}}

(9)微信支付查单API

9.1 查单接口的调用

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

9.1.1 WxPayController
 /*** 查询订单** @param orderNo* @return* @throws URISyntaxException* @throws IOException*/@ApiOperation("查询订单:测试订单状态用")@GetMapping("query/{orderNo}")public R queryOrder(@PathVariable String orderNo) throws Exception {log.info("查询订单");String bodyAsString = wxPayService.queryOrder(orderNo);return R.ok().setMessage("查询成功").data("bodyAsString", bodyAsString);}
9.1.2 WxPayService

接口:

String queryOrder(String orderNo) throws Exception;

实现:

  /*** 查单接口调用*/@SneakyThrows@Overridepublic String queryOrder(String orderNo) {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 = 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) { //处理成功,无返回Bodylog.info("成功");} else {log.info("Native下单失败,响应码 = " + statusCode + ",返回结果 = " +bodyAsString);throw new IOException("request failed");}return bodyAsString;} finally {response.close();}}

9.2 集成Spring Task

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

9.2.1 启动类添加注解
@EnableScheduling
9.2.2 测试定时任务

创建 task 包,创建 WxPayTask.java

package com.acerola.paymentdemo.task;import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;/*** @program: payment-demo* @description:* @author: Acerola* @create: 2022-03-28 17:12**/@Slf4j
@Component
public class WxPayTask {/*** 测试* (cron="秒 分 时 日 月 周")* *:每隔一秒执行* 0/3:从第0秒开始,每隔3秒执行一次* 1-3: 从第1秒开始执行,到第3秒结束执行* 1,2,3:第1、2、3秒执行* ?:不指定,若指定日期,则不指定周,反之同理*/@Scheduled(cron = "0/3 * * * * ?")public void task1() {log.info("task1 执行");}
}

9.3定时查找超时订单

9.3.1WxPayTask
    @Autowiredprivate OrderInfoService orderInfoService;@Autowiredprivate WxPayService wxPayService;/*** 从第0秒开始每隔30秒执行1次,查询创建超过5分钟,并且未支付的订单*/@Scheduled(cron = "0/30 * * * * ?")public void orderConfirm() throws Exception {log.info("orderConfirm 被执行......");List<OrderInfo> orderInfoList = orderInfoService.getNoPayOrderByDuration(5);for (OrderInfo orderInfo : orderInfoList) {String orderNo = orderInfo.getOrderNo();log.warn("超时订单 ===> {}", orderNo);//核实订单状态:调用微信支付查单接口wxPayService.checkOrderStatus(orderNo);}}
9.3.2 OrderInfoService

接口:

    List<OrderInfo> getNoPayOrderByDuration(int i);

实现:

    /*** 找出创建超过minutes分钟并且未支付的订单** @param minutes* @return*/@Overridepublic List<OrderInfo> getNoPayOrderByDuration(int minutes) {//minutes分钟之前的时间Instant instant = Instant.now().minus(Duration.ofMinutes(minutes));QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();queryWrapper.eq("order_status", OrderStatus.NOTPAY.getType());queryWrapper.le("create_time", instant);List<OrderInfo> orderInfoList = baseMapper.selectList(queryWrapper);return orderInfoList;}

9.4 处理超时订单

WxPayService

接口:

    void checkOrderStatus(String orderNo);

实现:

  /*** 根据订单号查询微信支付查单接口,核实订单状态* 如果订单已支付,则更新商户端订单状态,并记录支付日志* 如果订单未支付,则调用关单接口关闭订单,并更新商户端订单状态** @param orderNo*/@SneakyThrows@Overridepublic void checkOrderStatus(String orderNo)   {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(result);}if (WxTradeState.NOTPAY.getType().equals(tradeState)) {log.warn("核实订单未支付 ===> {}", orderNo);//如果订单未支付,则调用关单接口this.closeOrder(orderNo);//更新本地订单状态orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.CLOSED);}}
9.4.1 根据订单号获取订单

接口:

OrderInfo getOrderByOrderNo(String orderNo);

实现:

/*** 根据订单号获取订单* @param orderNo* @return*/@Overridepublic OrderInfo getOrderByOrderNo(String orderNo) {QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();queryWrapper.eq("order_no", orderNo);OrderInfo orderInfo = baseMapper.selectOne(queryWrapper);return orderInfo;}

(10) 申请退款API

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

10.1 创建退款单

RefundsInfoService

接口:

RefundInfo createRefundByOrderNo(String orderNo, String reason);

实现:

@Autowiredprivate OrderInfoService orderInfoService;/*** 根据订单号创建退款订单** @param orderNo* @return*/@Overridepublic 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);//退款原因//保存退款订单baseMapper.insert(refundInfo);return refundInfo;}

10.2 更新退款单

RefundInfoService

接口:

void updateRefund(String content);

实现:

/*** 记录退款记录** @param content*/@Overridepublic void updateRefund(String content) {//将json字符串转换成MapGson gson = new Gson();Map<String, String> resultMap = gson.fromJson(content, HashMap.class);//根据退款单编号修改退款单QueryWrapper<RefundInfo> queryWrapper = new QueryWrapper<>();queryWrapper.eq("refund_no", resultMap.get("out_refund_no"));//设置要修改的字段RefundInfo refundInfo = new RefundInfo();refundInfo.setRefundId(resultMap.get("refund_id"));//微信支付退款单号//查询退款和申请退款中的返回参数if (resultMap.get("status") != null) {refundInfo.setRefundStatus(resultMap.get("status"));//退款状态refundInfo.setContentReturn(content);//将全部响应结果存入数据库的content字段}//退款回调中的回调参数if (resultMap.get("refund_status") != null) {refundInfo.setRefundStatus(resultMap.get("refund_status"));//退款状态refundInfo.setContentNotify(content);//将全部响应结果存入数据库的content字段}//更新退款单baseMapper.update(refundInfo, queryWrapper);}

10.3 申请退款

10.3.1 WxPayController
@ApiOperation("申请退款")@PostMapping("/refunds/{orderNo}/{reason}")public R refunds(@PathVariable String orderNo, @PathVariable String reason)throws Exception {log.info("申请退款");wxPayService.refund(orderNo, reason);return R.ok();}
10.3.2 WxPayService

接口:

  void refund(String orderNo, String reason);

实现:

 @Autowiredprivate RefundInfoService refundsInfoService;
/*** 退款** @param orderNo* @param reason* @throws IOException*/@SneakyThrows@Transactional(rollbackFor = Exception.class)@Overridepublic void refund(String orderNo, String reason) {log.info("创建退款单记录");//根据订单编号创建退款单RefundInfo refundsInfo = refundsInfoService.createRefundByOrderNo(orderNo,reason);log.info("调用退款API");//调用统一下单APIString url =wxPayConfig.getDomain().concat(WxApiType.DOMESTIC_REFUNDS.getType());HttpPost httpPost = new HttpPost(url);// 请求body参数Gson gson = new Gson();Map paramsMap = new HashMap();paramsMap.put("out_trade_no", orderNo);//订单编号paramsMap.put("out_refund_no", refundsInfo.getRefundNo());//退款单编号paramsMap.put("reason", reason);//退款原因paramsMap.put("notify_url",wxPayConfig.getNotifyDomain().concat(WxNotifyType.REFUND_NOTIFY.getType()));//退款通知地址Map amountMap = new HashMap();amountMap.put("refund", refundsInfo.getRefund());//退款金额amountMap.put("total", refundsInfo.getTotalFee());//原订单金额amountMap.put("currency", "CNY");//退款币种paramsMap.put("amount", amountMap);//将参数转换成json字符串String jsonParams = gson.toJson(paramsMap);log.info("请求参数 ===> {}" + jsonParams);StringEntity entity = new StringEntity(jsonParams, "utf-8");entity.setContentType("application/json");//设置请求报文格式httpPost.setEntity(entity);//将请求报文放入请求对象httpPost.setHeader("Accept", "application/json");//设置响应报文格式//完成签名并执行请求,并完成验签CloseableHttpResponse response = wxPayClient.execute(httpPost);try {//解析响应结果String bodyAsString = EntityUtils.toString(response.getEntity());int statusCode = response.getStatusLine().getStatusCode();if (statusCode == 200) {log.info("成功, 退款返回结果 = " + bodyAsString);} else if (statusCode == 204) {log.info("成功");} else {throw new RuntimeException("退款异常, 响应码 = " + statusCode + ", 退款返回结果 = " + bodyAsString);}//更新订单状态orderInfoService.updateStatusByOrderNo(orderNo,OrderStatus.REFUND_PROCESSING);//更新退款单refundsInfoService.updateRefund(bodyAsString);} finally {response.close();}}

(11)查询退款API

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

11.1 查单接口的调用

11.1.1 WxPayController
    /*** 查询退款** @param refundNo* @return* @throws Exception*/@ApiOperation("查询退款:测试用")@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);}
11.1.2 WxPayService

接口:

    String queryRefund(String refundNo);

实现:

 @SneakyThrows@Overridepublic String queryRefund(String refundNo) {log.info("查询退款接口调用 ===> {}", refundNo);String url = String.format(WxApiType.DOMESTIC_REFUNDS_QUERY.getType(),refundNo);url = wxPayConfig.getDomain().concat(url);//创建远程Get 请求对象HttpGet httpGet = new HttpGet(url);httpGet.setHeader("Accept", "application/json");//完成签名并执行请求CloseableHttpResponse response = wxPayClient.execute(httpGet);try {String bodyAsString = EntityUtils.toString(response.getEntity());int statusCode = response.getStatusLine().getStatusCode();if (statusCode == 200) {log.info("成功, 查询退款返回结果 = " + bodyAsString);} else if (statusCode == 204) {log.info("成功");} else {throw new RuntimeException("查询退款异常, 响应码 = " + statusCode + ", 查询退款返回结果 = " + bodyAsString);}return bodyAsString;} finally {response.close();}}

11.2 定时查找退款中的订单

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

接口:

List<RefundInfo> getNoRefundOrderByDuration(int minutes);

实现:

    /*** 找出申请退款超过minutes分钟并且未成功的退款单** @param minutes* @return*/@Overridepublic List<RefundInfo> getNoRefundOrderByDuration(int minutes) {//minutes分钟之前的时间Instant instant = Instant.now().minus(Duration.ofMinutes(minutes));QueryWrapper<RefundInfo> queryWrapper = new QueryWrapper<>();queryWrapper.eq("refund_status", WxRefundStatus.PROCESSING.getType());queryWrapper.le("create_time", instant);List<RefundInfo> refundInfoList = baseMapper.selectList(queryWrapper);return refundInfoList;}

11.3 处理超时未退款订单

WxPayService

核实订单状态

接口:

void checkRefundStatus(String refundNo);

实现:

    /*** 根据退款单号核实退款单状态** @param refundNo* @return*/@Transactional(rollbackFor = Exception.class)@Overridepublic void checkRefundStatus(String refundNo) {log.warn("根据退款单号核实退款单状态 ===> {}", refundNo);//调用查询退款单接口String result = this.queryRefund(refundNo);//组装json请求体字符串Gson gson = new Gson();Map<String, String> resultMap = gson.fromJson(result, HashMap.class);//获取微信支付端退款状态String status = resultMap.get("status");String orderNo = resultMap.get("out_trade_no");if (WxRefundStatus.SUCCESS.getType().equals(status)) {log.warn("核实订单已退款成功 ===> {}", refundNo);//如果确认退款成功,则更新订单状态orderInfoService.updateStatusByOrderNo(orderNo,OrderStatus.REFUND_SUCCESS);//更新退款单refundsInfoService.updateRefund(result);}if (WxRefundStatus.ABNORMAL.getType().equals(status)) {log.warn("核实订单退款异常 ===> {}", refundNo);//如果确认退款成功,则更新订单状态orderInfoService.updateStatusByOrderNo(orderNo,OrderStatus.REFUND_ABNORMAL);//更新退款单refundsInfoService.updateRefund(result);}}

(12) 退款结果通知API

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

12.1 接收退款通知

WxPayController

/*** 退款结果通知* 退款状态改变后,微信会把相关退款结果发送给商户。*/@PostMapping("/refunds/notify")public String refundsNotify(HttpServletRequest request, HttpServletResponseresponse) {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);}}

12.2 处理订单和退款单

WxPayService

接口:

 void processRefund(Map<String, Object> bodyMap);

实现:

 /*** 处理退款单*/@SneakyThrows@Transactional(rollbackFor = Exception.class)@Overridepublic void processRefund(Map<String, Object> bodyMap)  {log.info("退款单");//解密报文String plainText = decryptFromResource(bodyMap);//将明文转换成mapGson 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();}}}

(13) 账单

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

13.1.1 WxPayController
 @ApiOperation("获取账单url:测试用")@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);}
13.1.2 WxPayService

接口:

String queryBill(String billDate, String type);

实现:

 /*** 申请账单** @param billDate* @param type* @return* @throws Exception*/@SneakyThrows@Overridepublic String queryBill(String billDate, String type)  {log.warn("申请账单接口调用 {}", billDate);String url = "";if ("tradebill".equals(type)) {url = WxApiType.TRADE_BILLS.getType();} else if ("fundflowbill".equals(type)) {url = WxApiType.FUND_FLOW_BILLS.getType();} else {throw new RuntimeException("不支持的账单类型");}url = wxPayConfig.getDomain().concat(url).concat("?bill_date=").concat(billDate);//创建远程Get 请求对象HttpGet httpGet = new HttpGet(url);httpGet.addHeader("Accept", "application/json");//使用wxPayClient发送请求得到响应CloseableHttpResponse response = wxPayClient.execute(httpGet);try {String bodyAsString = EntityUtils.toString(response.getEntity());int statusCode = response.getStatusLine().getStatusCode();if (statusCode == 200) {log.info("成功, 申请账单返回结果 = " + bodyAsString);} else if (statusCode == 204) {log.info("成功");} else {throw new RuntimeException("申请账单异常, 响应码 = " + statusCode + ", 申请账单返回结果 = " + bodyAsString);}//获取账单下载地址Gson gson = new Gson();Map<String, String> resultMap = gson.fromJson(bodyAsString,HashMap.class);return resultMap.get("download_url");} finally {response.close();}}

13.2 下载账单

13.2.1 WxPayController
   @ApiOperation("下载账单")@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);}
13.2.2 WxPayService

接口:

String downloadBill(String billDate, String type);

实现:

    @Autowiredprivate CloseableHttpClient wxPayNoSignClient;/*** 下载账单** @param billDate* @param type* @return* @throws Exception*/@SneakyThrows@Overridepublic String downloadBill(String billDate, String type)  {log.warn("下载账单接口调用 {}, {}", billDate, type);//获取账单url地址String downloadUrl = this.queryBill(billDate, type);//创建远程Get 请求对象HttpGet httpGet = new HttpGet(downloadUrl);httpGet.addHeader("Accept", "application/json");//使用wxPayClient发送请求得到响应CloseableHttpResponse response = wxPayNoSignClient.execute(httpGet);try {String bodyAsString = EntityUtils.toString(response.getEntity());int statusCode = response.getStatusLine().getStatusCode();if (statusCode == 200) {log.info("成功, 下载账单返回结果 = " + bodyAsString);} else if (statusCode == 204) {log.info("成功");} else {throw new RuntimeException("下载账单异常, 响应码 = " + statusCode + ", 下载账单返回结果 = " + bodyAsString);}return bodyAsString;} finally {response.close();}}

(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();
}
}


#### 13.2 下载账单##### 13.2.1 WxPayController```java@ApiOperation("下载账单")@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);}
13.2.2 WxPayService

接口:

String downloadBill(String billDate, String type);

实现:

    @Autowiredprivate CloseableHttpClient wxPayNoSignClient;/*** 下载账单** @param billDate* @param type* @return* @throws Exception*/@SneakyThrows@Overridepublic String downloadBill(String billDate, String type)  {log.warn("下载账单接口调用 {}, {}", billDate, type);//获取账单url地址String downloadUrl = this.queryBill(billDate, type);//创建远程Get 请求对象HttpGet httpGet = new HttpGet(downloadUrl);httpGet.addHeader("Accept", "application/json");//使用wxPayClient发送请求得到响应CloseableHttpResponse response = wxPayNoSignClient.execute(httpGet);try {String bodyAsString = EntityUtils.toString(response.getEntity());int statusCode = response.getStatusLine().getStatusCode();if (statusCode == 200) {log.info("成功, 下载账单返回结果 = " + bodyAsString);} else if (statusCode == 204) {log.info("成功");} else {throw new RuntimeException("下载账单异常, 响应码 = " + statusCode + ", 下载账单返回结果 = " + bodyAsString);}return bodyAsString;} finally {response.close();}}

SpringBoot+Vue 微信支付API V3相关推荐

  1. 微信支付API v3接口使用应用篇

    目录 前言 版本 应用 基础配置 1.申请商户API证书 2.设置接口密钥 3.下载平台证书 接口实测 微信支付API官方客户端 1.客户端 2.支付调起参数签名 3.回调通知 参考资料 前言 最近新 ...

  2. 微信支付API V3版本JAVA开发指南

    微信支付版本V3的Demo,在官方上下载下来,压根就是不能直接用的东西,你要想学会用,你就得一层一层的看源码,看文档,要求你事无巨细的做一个接入者. 如果接入API需要让人看源码来理解,我觉得是一件让 ...

  3. 微信支付API v3签名与验签-APP支付问题

    目录 使用API v3微信支付遇到的问题: 1.微信请求客户端配置 2.生成预付款订单 3.拼接字符串使用API v3签名 4.微信支付成功后通知 使用API v3微信支付遇到的问题: 1.jdk版本 ...

  4. 微信支付API v3 Native支付

    废话不多说直接上代码  不熟悉的直接私信我 依赖 <dependency><groupId>com.github.wechatpay-apiv3</groupId> ...

  5. 微信支付 API V3 JSAPI支付 JAVA下载账单

    下载账单 写这个主要是太气人了,开发文档未写具体的代码示例.网上各种搜索了一天都是V2接口的示例V3的标题党,感觉被欺骗了,太气人了(V2接口有个参数APPID,具体业务使用了多个APPID所以不合适 ...

  6. SpringBoot对接微信支付之JSAPI

    分享SpringBoot整合微信公众号支付项目,对接微信JSAPI支付类型遇到的问题和过程封装的工具类,目前已正常使用,有问题大家评论区互动哈,有需要源码的可以私信我. 1.创建SpringBoot项 ...

  7. SpringBoot集成微信支付V3

    河南循中网络科技有限公司 - 精心创作,详细分解,按照步骤,均可成功! 文章目录 吐槽! 此文章包含实现了哪些接口? 学习资料 集成微信支付V3 什么是"商户证书"?什么是&quo ...

  8. SpringBoot整合微信支付开发在线教育视频网站(完整版)

    目录 ├─code.zip ├─第 1 章项目介绍和前期准备 │  ├─1-1 SpringBoot整合微信支付开发在线教育视频站点介绍.TS │  ├─1-2 中大型公司里面项目开发流程讲解.TS ...

  9. 前端 VUE 微信支付 JSAPI

    在威信公众号之中的产品H5页面,在购买时需要直接唤起微信支付,完成投保.核保流程.今天分享自己在唤起微信支付中的一些体会,希望可以帮助到大家. 先给大家将官方的说明文档附上,感兴趣的可以直接看看    ...

  10. 【毕业设计】基于springboot + vue微信小程序商城

    目录 前言 创新点/亮点✨ 毕设目录 一.视频展示 二.系统介绍 三.项目地址 四.运行环境 五.设计模块 ①前台 ②后台 六.系统功能模块结构图 七. 准备阶段 ①使用真实支付 ②使用模拟支付 八. ...

最新文章

  1. 万字长文带你看尽深度学习中的各种卷积网络
  2. 【 FPGA 】FIR滤波器的采样速率与系统时钟速率不同时的资源消耗分析
  3. ACM网络赛金华赛区的一道关于树的题:Family Name List
  4. Gimmie — 一个创新的 GNOME 面板按次
  5. 讲解Linux服务器被黑解决方法
  6. 元宇宙iwemeta: 2021年云计算行业发展研究报告
  7. java的圆周率_java学习日记,圆周率的打印
  8. OTT交付如何超越传统广电交付,为用户带来高质量视频网络——对话Synamedia流媒体技术发展经理卢彦林...
  9. .NET中的正则表达式 (三)RegexCompilationInfo 类
  10. 大学生学编程系列」第五篇:自学编程需要多久才能找到工作?
  11. mysql修改表的备注信息_修改mysql 数据库的 表的列的备注信息
  12. 使用Fluent NHibernate和AngularJS的Master Chef(第1部分)ASP.NET Core MVC
  13. 12大深度学习开源框架(caffe,tensorflow,pytorch,mxnet等)汇总详解
  14. 无线短距通信技术标准:WIFI,蓝牙,ZigBee
  15. 这项技术曾应用于无人驾驶,荣耀10将其移植到手机上这样操作!
  16. 六石管理学:公司要有应付没钱的预案,包括裁员
  17. 数学建模方法 — 【01】模糊数学
  18. 进击的海姆达尔Heimdallr,2021年链游最后一趟财富专列
  19. 2021.06.29【R语言】丨png转pdf批量生成
  20. Android客户端与PC服务器如何实现Socket通信

热门文章

  1. linux7.5有哪些版本,CentOS Linux 7.5正式发布,基于Red Hat Enterprise Linux 7.5
  2. Internet Explorer无法打开Internet站点
  3. PowerBuilder -- 条码打印
  4. HTML编辑器UEditor的简单使用
  5. 生活随记 - 尝试与师傅沟通争取自己的权益
  6. html打印 去除页眉页脚,js客户端打印html并且去掉页眉、页脚
  7. 小型微利企业税收筹划策略探析
  8. 中国数据中心最新规划图,中国数据中心建设情况
  9. B站傅希鸣-ElasticSearch学习笔记(ES 入门)
  10. ubuntu18.04智能拼音候选字体调节方法