上周由于项目需要开通H5微信支付功能,于是在网上参考了很多例子,由于数据缺失,实用性不高,所以在此特地将SpringBoot整合H5微信支付的流程整理成文档,测试可用。
H5支付是指商户在微信客户端外的移动端网页展示商品或服务,用户在前述页面确认使用微信支付时,商户发起本服务呼起微信客户端进行支付。
主要用于触屏版的手机浏览器请求微信支付的场景。可以方便的从外部浏览器唤起微信支付。
申请入口:登录商户平台–>产品中心–>我的产品–>支付产品–>H5支付
注意:需要开通H5支付,并且做一些配置
微信官方体验链接:https://wxpay.wxutil.com/mch/pay/h5.v2.php,请在微信外浏览器打开。
1、用户在商户侧完成下单,使用微信支付进行支付
2、由商户后台向微信支付发起下单请求(调用统一下单接口)注:交易类型trade_type=MWEB
3、统一下单接口返回支付相关参数给商户后台,如支付跳转url(参数名“mweb_url”),商户通过mweb_url调起微信支付中间页
4、中间页进行H5权限的校验,安全性检查(此处常见错误请见下文)
5、如支付成功,商户后台会接收到微信侧的异步通知
6、用户在微信支付收银台完成支付或取消支付,返回商户页面(默认为返回支付发起页面)
7、商户在展示页面,引导用户主动发起支付结果的查询
8、商户后台判断是否接到收微信侧的支付结果通知,如没有,后台调用我们的订单查询接口确认订单状态
9、展示最终的订单支付结果给用户
H5支付文档
<dependency>
<groupId>com.github.wxpaygroupId>
<artifactId>wxpay-sdkartifactId>
<version>0.0.3version>
dependency>
<dependency>
<groupId>org.webjarsgroupId>
<artifactId>jqueryartifactId>
<version>3.3.1version>
dependency>
# 测试账号
pay:
wxpay:
appID: wxab8acb865bb1637e
mchID: 11473623
key: 2ab9071b06b9f739b950ddb41db2690d
sandboxKey: 3639bc1370e105aa65f10cd4fef2a3ef
certPath: /var/local/cert/apiclient_cert.p12
notifyUrl: http://65ta5j.natappfree.cc/wxpay/refund/notify
useSandbox: true
spring:
thymeleaf:
prefix: classpath:/templates/
suffix: .html
mode: HTML5
encoding: UTF-8
@Configuration
public class WebMvcConfiguration extends WebMvcConfigurationSupport {
@Override
protected void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/gotoWapPage").setViewName("gotoWapPay");
registry.addViewController("/gotoPagePage").setViewName("gotoPagePay");
registry.addViewController("/gotoH5Page").setViewName("gotoH5Page");
registry.addViewController("/h5PaySuccess").setViewName("h5PaySuccess");
super.addViewControllers(registry);
}
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
super.addResourceHandlers(registry);
}
}
/**
* 微信支付的参数配置
*
* @author mengday zhang
*/
@Data
@Slf4j
@ConfigurationProperties(prefix = "pay.wxpay")
public class MyWXPayConfig implements WXPayConfig{
/** 公众账号ID */
private String appID;
/** 商户号 */
private String mchID;
/** API 密钥 */
private String key;
/** API 沙箱环境密钥 */
private String sandboxKey;
/** API证书绝对路径 */
private String certPath;
/** 退款异步通知地址 */
private String notifyUrl;
private Boolean useSandbox;
/** HTTP(S) 连接超时时间,单位毫秒 */
private int httpConnectTimeoutMs = 8000;
/** HTTP(S) 读数据超时时间,单位毫秒 */
private int httpReadTimeoutMs = 10000;
/**
* 获取商户证书内容
*
* @return 商户证书内容
*/
@Override
public InputStream getCertStream() {
File certFile = new File(certPath);
InputStream inputStream = null;
try {
inputStream = new FileInputStream(certFile);
} catch (FileNotFoundException e) {
log.error("cert file not found, path={}, exception is:{}", certPath, e);
}
return inputStream;
}
@Override
public String getKey(){
if (useSandbox) {
return sandboxKey;
}
return key;
}
}
/**
* WXPayClient
*
* 对WXPay的简单封装,处理支付密切相关的逻辑.
*
* @author Mengday Zhang
* @version 1.0
* @since 2018/6/16
*/
@Slf4j
public class WXPayClient extends WXPay {
/** 密钥算法 */
private static final String ALGORITHM = "AES";
/** 加解密算法/工作模式/填充方式 */
private static final String ALGORITHM_MODE_PADDING = "AES/ECB/PKCS5Padding";
/** 用户支付中,需要输入密码 */
private static final String ERR_CODE_USERPAYING = "USERPAYING";
private static final String ERR_CODE_AUTHCODEEXPIRE = "AUTHCODEEXPIRE";
/** 交易状态: 未支付 */
private static final String TRADE_STATE_NOTPAY = "NOTPAY";
/** 用户输入密码,尝试30秒内去查询支付结果 */
private static Integer remainingTimeMs = 10000;
private WXPayConfig config;
public WXPayClient(WXPayConfig config, WXPayConstants.SignType signType, boolean useSandbox) {
super(config, signType, useSandbox);
this.config = config;
}
/**
*
* 刷卡支付
*
* 对WXPay#microPay(Map)增加了当支付结果为USERPAYING时去轮询查询支付结果的逻辑处理
*
* 注意:该方法没有处理return_code=FAIL的情况,暂时不考虑网络问题,这种情况直接返回错误
*
* @param reqData
* @return
* @throws Exception
*/
public Map<String, String> microPayWithPOS(Map<String, String> reqData) throws Exception {
// 开始时间(毫秒)
long startTimestampMs = System.currentTimeMillis();
Map<String, String> responseMapForPay = super.microPay(reqData);
log.info(responseMapForPay.toString());
// // 先判断 协议字段返回(return_code),再判断 业务返回,最后判断 交易状态(trade_state)
// 通信标识,非交易标识
String returnCode = responseMapForPay.get("return_code");
if (WXPayConstants.SUCCESS.equals(returnCode)) {
String errCode = responseMapForPay.get("err_code");
// 余额不足,信用卡失效
if (ERR_CODE_USERPAYING.equals(errCode) || "SYSTEMERROR".equals(errCode) || "BANKERROR".equals(errCode)) {
Map<String, String> orderQueryMap = null;
Map<String, String> requestData = new HashMap<>();
requestData.put("out_trade_no", reqData.get("out_trade_no"));
// 用户支付中,需要输入密码或系统错误则去重新查询订单API err_code, result_code, err_code_des
// 每次循环时的当前系统时间 - 开始时记录的时间 > 设定的30秒时间就退出
while (System.currentTimeMillis() - startTimestampMs < remainingTimeMs) {
// 商户收银台得到USERPAYING状态后,经过商户后台系统调用【查询订单API】查询实际支付结果。
orderQueryMap = super.orderQuery(requestData);
String returnCodeForQuery = orderQueryMap.get("return_code");
if (WXPayConstants.SUCCESS.equals(returnCodeForQuery)) {
// 通讯成功
String tradeState = orderQueryMap.get("trade_state");
if (WXPayConstants.SUCCESS.equals(tradeState)) {
// 如果成功了直接将查询结果返回
return orderQueryMap;
}
// 如果支付结果仍为USERPAYING,则每隔5秒循环调用【查询订单API】判断实际支付结果
Thread.sleep(1000);
}
}
// 如果用户取消支付或累计30秒用户都未支付,商户收银台退出查询流程后继续调用【撤销订单API】撤销支付交易。
String tradeState = orderQueryMap.get("trade_state");
if (TRADE_STATE_NOTPAY.equals(tradeState) || ERR_CODE_USERPAYING.equals(tradeState) || ERR_CODE_AUTHCODEEXPIRE.equals(tradeState)) {
Map<String, String> reverseMap = this.reverse(requestData);
String returnCodeForReverse = reverseMap.get("return_code");
String resultCode = reverseMap.get("result_code");
if (WXPayConstants.SUCCESS.equals(returnCodeForReverse) && WXPayConstants.SUCCESS.equals(resultCode)) {
// 如果撤销成功,需要告诉客户端已经撤销订单了
responseMapForPay.put("err_code_des", "用户取消支付或尚未支付,后台已经撤销该订单,请重新支付!");
}
}
}
}
return responseMapForPay;
}
/**
* 从request的inputStream中获取参数
* @param request
* @return
* @throws Exception
*/
public Map<String, String> getNotifyParameter(HttpServletRequest request) throws Exception {
InputStream inputStream = request.getInputStream();
ByteArrayOutputStream outSteam = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int length = 0;
while ((length = inputStream.read(buffer)) != -1) {
outSteam.write(buffer, 0, length);
}
outSteam.close();
inputStream.close();
// 获取微信调用我们notify_url的返回信息
String resultXml = new String(outSteam.toByteArray(), "utf-8");
Map<String, String> notifyMap = WXPayUtil.xmlToMap(resultXml);
return notifyMap;
}
/**
* 解密退款通知
*
* public Map<String, String> decodeRefundNotify(HttpServletRequest request) throws Exception {
// 从request的流中获取参数
Map<String, String> notifyMap = this.getNotifyParameter(request);
log.info(notifyMap.toString());
String reqInfo = notifyMap.get("req_info");
//(1)对加密串A做base64解码,得到加密串B
byte[] bytes = new BASE64Decoder().decodeBuffer(reqInfo);
//(2)对商户key做md5,得到32位小写key* ( key设置路径:微信商户平台(pay.weixin.qq.com)-->账户设置-->API安全-->密钥设置 )
Cipher cipher = Cipher.getInstance(ALGORITHM_MODE_PADDING);
SecretKeySpec key = new SecretKeySpec(WXPayUtil.MD5(config.getKey()).toLowerCase().getBytes(), ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, key);
//(3)用key*对加密串B做AES-256-ECB解密(PKCS7Padding)
// java.security.InvalidKeyException: Illegal key size or default parameters
// https://www.cnblogs.com/yaks/p/5608358.html
String responseXml = new String(cipher.doFinal(bytes),"UTF-8");
Map<String, String> responseMap = WXPayUtil.xmlToMap(responseXml);
return responseMap;
}
/**
* 获取沙箱环境验签秘钥API
* 获取验签秘钥API文档
* @return
* @throws Exception
*/
public Map<String, String> getSignKey() throws Exception {
Map<String, String> reqData = new HashMap<>();
reqData.put("mch_id", config.getMchID());
reqData.put("nonce_str", WXPayUtil.generateNonceStr());
String sign = WXPayUtil.generateSignature(reqData, config.getKey(), WXPayConstants.SignType.MD5);
reqData.put("sign", sign);
String responseXml = this.requestWithoutCert("https://api.mch.weixin.qq.com/sandboxnew/pay/getsignkey", reqData,
config.getHttpConnectTimeoutMs(), config.getHttpReadTimeoutMs());
Map<String, String> responseMap = WXPayUtil.xmlToMap(responseXml);
return responseMap;
}
}
/**
* 微信支付配置
*
* @author mengday zhang
*/
@Configuration
@EnableConfigurationProperties(MyWXPayConfig.class)
public class WXPayConfiguration {
@Autowired
private MyWXPayConfig wxPayConfig;
/**
* useSandbox 沙盒环境
* @return
*/
@Bean
public WXPay wxPay() {
return new WXPay(wxPayConfig, WXPayConstants.SignType.MD5, wxPayConfig.getUseSandbox() );
}
@Bean
public WXPayClient wxPayClient() {
return new WXPayClient(wxPayConfig, WXPayConstants.SignType.MD5, wxPayConfig.getUseSandbox());
}
}
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
head>
<body style="font-size: 30px">
<h3>购买商品:可口可乐h3>
<h3>价格:4h3>
<h3>数量:10个h3>
<button style="width: 100%; height: 60px; alignment: center; background: #b49e8f" onclick="commitOrder()">提交订单button>
<script src="http://localhost:8080/webjars/jquery/3.3.1/jquery.js">script>
<script>
function commitOrder() {
$.ajax({
type: "POST",
url: "http://localhost:8080/wxpay/h5pay/order",
data: null,
success: function(data) {
console.log(data);
var redirectUrl = "http://localhost:8080/h5PaySuccess";
var mwebUrl = data.mweb_url+"&redirect_url="+encodeURIComponent(redirectUrl);
window.location.href=mwebUrl;
}
})
}
script>
body>
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Titletitle>
head>
<body>
<h1>微信支付-H5支付成功h1>
body>
html>
/**
* 微信支付-H5支付.
*
* detailed description
*
* @author Mengday Zhang
* @version 1.0
* @since 2018/6/18
*/
@Slf4j
@RestController
@RequestMapping("/wxpay/h5pay")
public class WXPayH5PayController {
@Autowired
private WXPay wxPay;
@Autowired
private WXPayClient wxPayClient;
/**
* 使用沙箱支付的金额必须是用例中指定的金额,也就是 1.01 元,1.02元等,不能是你自己的商品的实际价格,必须是这个数。
* 否则会报错:沙箱支付金额(2000)无效,请检查需要验收的case
* @return
* @throws Exception
*/
@PostMapping("/order")
public Object h5pay() throws Exception {
Map<String, String> reqData = new HashMap<>();
reqData.put("out_trade_no", String.valueOf(System.nanoTime()));
reqData.put("trade_type", "MWEB");
reqData.put("product_id", "1");
reqData.put("body", "商户下单");
// 订单总金额,单位为分
reqData.put("total_fee", "101");
// APP和网页支付提交用户端ip,Native支付填调用微信支付API的机器IP。
reqData.put("spbill_create_ip", "14.23.150.211");
// 异步接收微信支付结果通知的回调地址,通知url必须为外网可访问的url,不能携带参数。
reqData.put("notify_url", "http://3sbqi7.natappfree.cc/wxpay/h5pay/notify");
// 自定义参数, 可以为终端设备号(门店号或收银设备ID),PC网页或公众号内支付可以传"WEB"
reqData.put("device_info", "");
// 附加数据,在查询API和支付通知中原样返回,可作为自定义参数使用。
reqData.put("attach", "");
reqData.put("scene_info", "{\"h5_info\": {\"type\":\"Wap\",\"wap_url\": \"http://3sbqi7.natappfree.cc\",\"wap_name\": \"腾讯充值\"}}");
Map<String, String> responseMap = wxPay.unifiedOrder(reqData);
log.info(responseMap.toString());
String returnCode = responseMap.get("return_code");
String resultCode = responseMap.get("result_code");
if (WXPayConstants.SUCCESS.equals(returnCode) && WXPayConstants.SUCCESS.equals(resultCode)) {
// 预支付交易会话标识
String prepayId = responseMap.get("prepay_id");
// 支付跳转链接(前端需要在该地址上拼接redirect_url,该参数不是必须的)
// 正常流程用户支付完成后会返回至发起支付的页面,如需返回至指定页面,则可以在MWEB_URL后拼接上redirect_url参数,来指定回调页面
// 需对redirect_url进行urlencode处理
// TODO 正常情况下这里应该是普通的链接,不知道这里为何是weixin://这样的链接,不知道是不是微信公众平台上的配置少配置了;
// 由于没有实际账号,还没找到为啥不是普通链接的原因
String mwebUrl = responseMap.get("mweb_url");
}
return responseMap;
}
/**
* 注意:如果是沙箱环境,一提交订单就会立即异步通知,而无需拉起微信支付收银台的中间页面
* @param request
* @throws Exception
*/
@RequestMapping("/notify")
public void payNotify(HttpServletRequest request, HttpServletResponse response) throws Exception{
Map<String, String> reqData = wxPayClient.getNotifyParameter(request);
log.info(reqData.toString());
String returnCode = reqData.get("return_code");
String resultCode = reqData.get("result_code");
if (WXPayConstants.SUCCESS.equals(returnCode) && WXPayConstants.SUCCESS.equals(resultCode)) {
boolean signatureValid = wxPay.isPayResultNotifySignatureValid(reqData);
if (signatureValid) {
// TODO 业务处理
Map<String, String> responseMap = new HashMap<>(2);
responseMap.put("return_code", "SUCCESS");
responseMap.put("return_msg", "OK");
String responseXml = WXPayUtil.mapToXml(responseMap);
response.setContentType("text/xml");
response.getWriter().write(responseXml);
response.flushBuffer();
}
}
}
}
参考 IntelliJ IDEA lombok插件的安装和使用
参考 微信官方文档 微信支付常见问题 微信返回错误提示
参考 浅析微信支付:如何使用沙箱环境测试
注意:本文档中 useSandbox: true 为沙箱测试环境,返回微信地址为 weixin://这样的链接,提交订单就会立即异步通知,不会拉起微信支付收银台的中间页面,当 useSandbox: false 时,切换为生产环境,浏览器会跳转至微信支付收银台的中间页面。
正常流程用户支付完成后会返回至发起支付的页面,如需返回至指定页面,则可以在MWEB_URL后拼接上redirect_url参数,来指定回调页面。
如:
您希望用户支付完成后跳转至https://www.wechatpay.com.cn,则可以做如下处理:
假设您通过统一下单接口获到的MWEB_URL= https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx20161110163838f231619da20804912345&package=1037687096
则拼接后的地址为MWEB_URL= https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx20161110163838f231619da20804912345&package=1037687096&redirect_url=https%3A%2F%2Fwww.wechatpay.com.cn
注意:
注意MWEB_URL是普通的链接,不是微信weixin://短链接(useSandbox: false)
需对redirect_url进行urlencode处理
由于设置redirect_url后,回跳指定页面的操作可能发生在:
微信支付中间页调起微信收银台后超过5秒
用户点击“取消支付“或支付完成后点“完成”按钮。因此无法保证页面回跳时,支付流程已结束,所以商户设置的redirect_url地址不能自动执行查单操作,应让用户去点击按钮触发查单操作。
本博客参考 :https://blog.csdn.net/vbirdbest/article/details/80726616 原创文档
作者博客网址:IDEA SPringBoot 整合H5微信支付