前言
接第四章从零玩转系列之微信支付实战PC端支付下单接口搭建后续.
此篇文章过长我将分几个阶段的文章发布(项目源码都有,小程序和PC端),至此微信支付Native支付完成.此篇文章过长我将分几个阶段的文章发布(项目源码都有,小程序和PC端)
在此之前已经更新了微信支付开篇、微信支付安全、微信实战基础框架搭建、本次更新为微信支付实战PC端接口搭建,实战篇分为几个章节因为代码量确实有点多哈.
- 第一章从零玩转系列之微信支付开篇
- 第二章从零玩转系列之微信支付安全
- 第三章从零玩转系列之微信支付实战基础框架搭建
- 第四章从零玩转系列之微信支付实战PC端支付下单接口搭建
- 第五章从零玩转系列之微信支付实战PC端支付微信回调接口搭建
- 第六章从零玩转系列之微信支付实战PC端支付微信取消订单接口搭建
- 第七章从零玩转系列之微信支付实战PC端支付微信退款订单接口搭建
- 第八章从零玩转系列之微信支付实战PC端项目构建Vue3+Vite+页面基础搭建
- 第九章从零玩转系列之微信支付实战PC端装修下单页面
- 第十章从零玩转系列之微信支付实战PC端装修我的订单下单页面
- 第十一章从零玩转系列之微信支付实战PC端我的订单接入退款取消接口
- 第十二章从零玩转系列之微信支付实战Uni—App基础项目搭建
本次项目使用技术栈
后端: SpringBoot3.1.x、Mysql8.0、MybatisPlus
前端: Vue3、Vite、ElementPlus
小程序: Uniapp、Uview
Native模式回调
当用户支付完成时候微信会下发一个回调到我们系统当中
该链接是通过基础下单接口中的请求参数notify_url来设置的,要求必须为https地址。请确保回调URL是外部可正常访问的,且不能携带后缀参数,否则可能导致商户无法接收到微信的回调通知信息。回调URL示例: “https://xxxxxx.com/api/wx-pay/native/notify”
通知规则
用户支付完成后,微信会把相关支付结果和用户信息发送给商户,商户需要接收处理该消息,并返回应答。
对后台通知交互时,如果微信收到商户的应答不符合规范或超时,微信认为通知失败,微信会通过一定的策略定期重新发起通知,尽可能提高通知的成功率,但微信不保证通知最终能成功。(通知频率为15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h - 总计 24h4m)
OK 我们在下单的时候设置了回调必须是HTTPS的SSL证书的
搭建本地调试 到时候上线的时候就替换域名即可
同学们可以使用免费的内网穿透,使用方式官方文档很详细仔细看我这就不讲解.
- https://www.ngrok.cc/ Sunny-Ngrok
提供免费内网穿透服务,免费服务器支持绑定自定义域名
管理内网服务器,内网web进行演示
快速开发微信程序和第三方支付平台调试
本地WEB外网访问、本地开发微信、TCP端口转发
本站新增FRP服务器,基于 FRP 实现https、udp转发
无需任何配置,下载客户端之后直接一条命令让外网访问您的内网不再是距离
目前博主使用的是花生壳 收费也就6块钱 给了两个SSL的域名速度还可以
- https://hsk.oray.com/ 花生壳🥜 so easy to happy的东西
- 无需依赖公网IP、无需配置路由器,花生壳支持在客户端上
- 添加端口映射,快速将内网服务发布到外网
开启内网穿透代理地址到本地 127.0.0.1:9080
修改 wxpay.properties 当中 wxpay.notify-domain 参数为你的内网穿透地址
支付通知
通知报文
支付结果通知是以POST 方法访问商户设置的通知url,通知的数据以JSON 格式通过请求主体(BODY)传输。通知的数据包括了加密的支付结果详情。
(注:由于涉及到回调加密和解密,商户必须先设置好apiv3秘钥后才能解密回调通知,apiv3秘钥设置文档指引详见APIv3秘钥设置指引)
上面的为商户APIV3的密钥之前我们已经设置好了还未设置的请参考开篇->获取APIv3秘钥(后续都是使用这个秘钥)
通知签名
加密不能保证通知请求来自微信。微信会对发送给商户的通知进行签名,并将签名值放在通知的HTTP头Wechatpay-Signature。商户应当验证签名,以确认请求来自微信,而不是其他的第三方。签名验证的算法请参考 《微信支付API v3签名验证》。
官方话语我就不说了感兴趣的去看文档详细的
总结一下回调需要干的事情
1.签名验证
处理签名验证
构造验签名串
首先,商户先从应答中获取以下信息。
- HTTP头Wechatpay-Timestamp 中的应答时间戳。
- HTTP头Wechatpay-Nonce 中的应答随机串。
- 应答主体(response Body),需要按照接口返回的顺序进行验签,错误的顺序将导致验签失败。
然后,请按照以下规则构造应答的验签名串。签名串共有三行,行尾以\n 结束,包括最后一行。\n为换行符(ASCII编码值为0x0A)。若应答报文主体为空(如HTTP状态码为204 No Content),最后一行仅为一个\n换行符。
应答时间戳\n 应答随机串\n 应答报文主体\n
我们可以看微信它是咋验证的我们就根据文档的要求改造一下子就行idea 按两下 shift 搜索 WechatPay2Validator
引用地址: com.wechat.pay.contrib.apache.httpclient.auth.WechatPay2Validator
好像啊 直接Copy 新增wechat文件夹复制到该文件夹当中 命名为 WechatPay2ValidatorForRequest
模仿微信验证签名,自定义支付通知API验证签名,针对通知请求的签名验证
改造构造函数
// 回调报文 protected final String body; // 回调唯一ID 没啥用反正原来存在我们就放在这呗 protected final String requestId; /** * 微信验证器 * * @param verifier 验证器 * @param requestId 请求id * @param body 微信回调的body */ public WechatPay2ValidatorForRequest(Verifier verifier, String requestId, String body) { this.verifier = verifier; this.requestId = requestId; this.body = body; }
改造验证方法
/** * 验证 * * @param request 请求 * @return boolean 是否成功 * @throws IOException ioexception */ 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, request.getHeader(REQUEST_ID)); } } catch (IllegalArgumentException e) { log.warn(e.getMessage()); return false; } return true; } /** * 构建验证签名消息 * 参考文档:<a href="https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay4_1.shtml">参考文档</a> * <p> * 构造验签名串 * 首先,商户先从应答中获取以下信息。 * <p> * HTTP头Wechatpay-Timestamp 中的应答时间戳。 * HTTP头Wechatpay-Nonce 中的应答随机串。 * 应答主体(response Body),需要按照接口返回的顺序进行验签,错误的顺序将导致验签失败。 * 然后,请按照以下规则构造应答的验签名串。签名串共有三行,行尾以\n 结束,包括最后一行。\n为换行符(ASCII编码值为0x0A)。 * 若应答报文主体为空(如HTTP状态码为204 No Content),最后一行仅为一个\n换行符。 * <p> ************************************ * 应答时间戳\n * 应答随机串\n * 应答报文主体\n ************************************ * <p> * * @param request 请求 * @return {@link String} * @throws IOException ioexception */ 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"; }
改造验证回调参数
/** * 验证参数 * * @param request 请求 */ protected final void validateParameters(HttpServletRequest request) { // NOTE: ensure HEADER_WECHAT_PAY_TIMESTAMP at last String[] headers = {WECHAT_PAY_SERIAL, WECHAT_PAY_SIGNATURE, WECHAT_PAY_NONCE, WECHAT_PAY_TIMESTAMP}; // 这些头必须存在否则直接是伪造 String header = null; for (String headerName : headers) { header = request.getHeader(headerName); if (header == null) { throw parameterError("empty [%s], request-id=[%s]", headerName, requestId); } } // 循环完毕直接默认被赋值是时间戳 String timestampStr = header; try { // 验证过期应答 Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestampStr)); // 拒绝过期应答 if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= RESPONSE_EXPIRED_MINUTES) { throw parameterError("timestamp=[%s] expires, request-id=[%s]", timestampStr, requestId); } } catch (DateTimeException | NumberFormatException e) { throw parameterError("invalid timestamp=[%s], request-id=[%s]", timestampStr, requestId); } }
完整代码 自定义验证签名器
package com.yby6.wechat; import com.wechat.pay.contrib.apache.httpclient.auth.Verifier; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.DateTimeException; import java.time.Duration; import java.time.Instant; import static com.wechat.pay.contrib.apache.httpclient.constant.WechatPayHttpHeaders.*; /** * 模仿微信验证签名,自定义支付通知API验证签名,针对通知请求的签名验证 * * @author Yang Shuai * Create By 2023/06/18 */ 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 body; protected final String requestId; /** * 微信验证器 * * @param verifier 验证器 * @param requestId 请求id * @param body 微信回调的body */ public WechatPay2ValidatorForRequest(Verifier verifier, String requestId, String body) { this.verifier = verifier; this.requestId = requestId; this.body = body; } /** * 参数错误 * * @param message 消息 * @return {@link IllegalArgumentException} */ protected static IllegalArgumentException parameterError(String message, Object... args) { message = String.format(message, args); return new IllegalArgumentException("parameter error: " + message); } /** * 验证失败 * * @param message 消息 * @return {@link IllegalArgumentException} */ protected static IllegalArgumentException verifyFail(String message, Object... args) { message = String.format(message, args); return new IllegalArgumentException("signature verify fail: " + message); } /** * 验证 * * @param request 请求 * @return boolean 是否成功 * @throws IOException ioexception */ 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, request.getHeader(REQUEST_ID)); } } catch (IllegalArgumentException e) { log.warn(e.getMessage()); return false; } return true; } /** * 验证参数 * * @param request 请求 */ protected final void validateParameters(HttpServletRequest request) { // NOTE: ensure HEADER_WECHAT_PAY_TIMESTAMP at last String[] headers = {WECHAT_PAY_SERIAL, WECHAT_PAY_SIGNATURE, WECHAT_PAY_NONCE, WECHAT_PAY_TIMESTAMP}; // 这些头必须存在否则直接是伪造 String header = null; for (String headerName : headers) { header = request.getHeader(headerName); if (header == null) { throw parameterError("empty [%s], request-id=[%s]", headerName, requestId); } } // 循环完毕直接默认被赋值是时间戳 String timestampStr = header; try { // 验证过期应答 Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestampStr)); // 拒绝过期应答 if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= RESPONSE_EXPIRED_MINUTES) { throw parameterError("timestamp=[%s] expires, request-id=[%s]", timestampStr, requestId); } } catch (DateTimeException | NumberFormatException e) { throw parameterError("invalid timestamp=[%s], request-id=[%s]", timestampStr, requestId); } } /** * 构建验证签名消息 * 参考文档:<a href="https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay4_1.shtml">参考文档</a> * <p> * 构造验签名串 * 首先,商户先从应答中获取以下信息。 * <p> * HTTP头Wechatpay-Timestamp 中的应答时间戳。 * HTTP头Wechatpay-Nonce 中的应答随机串。 * 应答主体(response Body),需要按照接口返回的顺序进行验签,错误的顺序将导致验签失败。 * 然后,请按照以下规则构造应答的验签名串。签名串共有三行,行尾以\n 结束,包括最后一行。\n为换行符(ASCII编码值为0x0A)。 * 若应答报文主体为空(如HTTP状态码为204 No Content),最后一行仅为一个\n换行符。 * <p> ************************************ * 应答时间戳\n * 应答随机串\n * 应答报文主体\n ************************************ * <p> * * @param request 请求 * @return {@link String} * @throws IOException ioexception */ 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"; } }
处理报文解密
2.验证成功后解密加密的报文
参数解密
下面详细描述对通知数据进行解密的流程:
1、用商户平台上设置的APIv3密钥【微信商户平台—>账户设置—>API安全—>设置APIv3密钥】,记为key;
2、针对resource.algorithm中描述的算法(目前为AEAD_AES_256_GCM),取得对应的参数nonce和associated_data;
3、使用key、nonce和associated_data,对数据密文resource.ciphertext进行解密,得到JSON形式的资源对象;
注: AEAD_AES_256_GCM算法的接口细节,请参考rfc5116。微信支付使用的密钥key长度为32个字节,随机串nonce长度12个字节,associated_data长度小于16个字节并可能为空字符串。
证书和回调报文解密
为了保证安全性,微信支付在回调通知和平台证书下载接口中,对关键信息进行了AES-256-GCM加密。本章节详细介绍了加密报文的格式,以及如何进行解密。
微信返回来的加密报文格式
AES-GCM是一种NIST标准的认证加密算法, 是一种能够同时保证数据的保密性、 完整性和真实性的一种加密模式。它最广泛的应用是在TLS中。
证书和回调报文使用的加密密钥为APIv3密钥。
对于加密的数据,我们使用了一个独立的JSON对象来表示。为了方便阅读,示例做了Pretty格式化,并加入了注释。
{ "original_type": "transaction", // 加密前的对象类型 "algorithm": "AEAD_AES_256_GCM", // 加密算法 // Base64编码后的密文 "ciphertext": "...", // 加密使用的随机串初始化向量) "nonce": "...", // 附加数据包(可能为空) "associated_data": "" }
⚠️ 加密的随机串,跟签名时使用的随机串没有任何关系,是不一样的。
解密
算法接口的细节,可以参考RFC 5116。
大部分编程语言(较新版本)都支持了AEAD_AES_256_GCM 。开发者可以参考下列的示例,了解如何使用您的编程语言实现解密。
我们引入的SDK已经有工具类直接用 com.wechat.pay.contrib.apache.httpclient.util.AesUtil
创建处理返回的 \n 的报文转json
package com.yby6.wechat; import cn.hutool.json.JSONUtil; import jakarta.servlet.http.HttpServletRequest; import java.io.BufferedReader; import java.io.IOException; import java.util.HashMap; import java.util.Map; /** * 用于解析微信支付回调的数据 * * @author Yang Shuai * Create By 2023/06/14 */ public class HttpUtils { /** * 分析数据 * * @param request 请求 * @return {@link Map}<{@link String}, {@link Object}> */ public static Map<String, Object> analysisData(HttpServletRequest request) { String body = HttpUtils.readData(request); return JSONUtil.toBean(body, HashMap.class); } /** * 将通知参数转化为字符串 */ 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(); } } } } }
知识点就这些了 实现一手
创建 nativeNotify 映射方法
/** * 支付通知->微信支付通过支付通知接口将用户支付成功消息通知给商户 * 参考:<a href="https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_5_5.shtml">...</a> */ @PostMapping("/notify") public Map<String, String> nativeNotify(HttpServletRequest request, HttpServletResponse response) { log.info("接收到微信服务回调......"); try { //处理通知参数 String body = HttpUtils.readData(request); Map<String, Object> bodyMap = JSONUtil.toBean(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); return WechatRep.fail(); } log.info("通知验签成功:{}", bodyMap); log.info("回调业务处理完毕"); // 成功应答 response.setStatus(200); return WechatRep.ok(); } catch (Exception e) { log.error("处理微信回调失败:", e); // 失败应答 response.setStatus(500); return WechatRep.fail(); } }