微信商户平台:https://pay.weixin.qq.com/
场景:Native支付
步骤:提交资料 => 签署协议 => 获取商户
微信公众平台:https://mp.weixin.qq.com/
步骤:注册服务号 => 服务号认证 => 获取APPID => 绑定商户号
APIv3版本的接口需要此秘钥
步骤:登录商户平台 => 选择账户中心 => 安全中心 => API安全 => 设置APIv3密钥
随机密码生成工具:https://suijimimashengcheng.bmcx.com/
APIv3版本的所有接口都需要;APIv2版本的高级接口需要(如:退款、企业红包、企业付款等)
步骤:登录商户平台 => 选择账户中心 => 安全中心 => API安全 => 申请API证书 => 妥善管理
通过编程动态获取,后续有教程
https://gitee.com/xzxwbb/wxpayment-demo
【尚硅谷】微信支付&支付宝支付,一套搞定Java在线支付开发教程
demo中的私钥文件,商户的参数由上面的视频资料提供
官方提供的SDK仓库,帮助我们完成开发。实现了请求签名的生成和应答签名的验证。
<dependency>
<groupId>com.github.wechatpay-apiv3groupId>
<artifactId>wechatpay-apache-httpclientartifactId>
<version>0.4.8version>
dependency>
# 示例:私钥存储在文件
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(
new FileInputStream("/path/to/apiclient_key.pem"));
# 示例:私钥为String字符串
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(
new ByteArrayInputStream(privateKey.getBytes("utf-8")));
通过第三方的工具来实现商户私钥签名和微信公钥验签的复杂工作
/**
* 获取签名验证器
*
* @return
*/
@Bean
public Verifier getVerifier() throws Exception {
// 获取证书管理器实例
CertificatesManager certificatesManager = CertificatesManager.getInstance();
// 向证书管理器增加需要自动更新平台证书的商户信息
certificatesManager.putMerchant(
mchId,
new WechatPay2Credentials(mchId, new PrivateKeySigner(mchSerialNo, getPrivateKey(privateKeyPath))),
//对称加密的密钥
apiV3Key.getBytes(StandardCharsets.UTF_8));
// ... 若有多个商户号,可继续调用putMerchant添加商户信息
// 从证书管理器中获取verifier
Verifier verifier = certificatesManager.getVerifier(mchId);
return verifier;
}
/**
* 获取 HttpClient 对象
*
* @param verifier 签名验证器
* @return
*/
@Bean(name = "wxPayClient")
public CloseableHttpClient getWxPayClient(Verifier verifier) {
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
.withMerchant(mchId, mchSerialNo, getPrivateKey(privateKeyPath))
.withValidator(new WechatPay2Validator(verifier));
// ... 接下来,你仍然可以通过builder设置各种参数,来配置你的HttpClient
// 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签,并进行证书自动更新
CloseableHttpClient httpClient = builder.build();
return httpClient;
}
/**
* 生成订单
* @param productId
* @return
*/
private OrderInfo generateOrderInfo(Long productId) {
OrderInfo orderInfo = new OrderInfo();
orderInfo.setTitle("test")
.setOrderNo(OrderNoUtils.getOrderNo())
.setProductId(productId)
.setTotalFee(1)
.setOrderStatus(OrderStatus.NOTPAY.getType());
return orderInfo;
}
/**
* 生成请求json
* @param orderInfo
* @return
*/
private String generateJsonParams(OrderInfo orderInfo) {
Gson gson = new Gson();
HashMap 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字符串
return gson.toJson(paramsMap);
}
/**
* 生成HttpPost
* @param jsonParams
* @return
*/
private HttpPost generateHttpPost(String jsonParams, String url) {
//拼接请求URL
HttpPost httpPost = new HttpPost(url);
//设置请求头,编码集,内容格式
StringEntity entity = new StringEntity(jsonParams, "utf-8");
entity.setContentType("application/json");
httpPost.setEntity(entity);
httpPost.setHeader("Accept", "application/json");
return httpPost;
}
String url = wxPayConfig.getDomain().concat(WxApiType.NATIVE_PAY.getType());//Mative下单的url
HttpPost httpPost = generateHttpPost(jsonParams, url);
//完成签名并执行请求
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) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("Native下单失败,响应码 = " + statusCode+ ",返回结果 = " + bodyAsString);
throw new IOException("request failed");
}
Gson gson = new Gson();
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();
}
//package.json
"vue-qriously": "^1.1.1",
// main.js
import VueQriously from 'vue-qriously'
Vue.use(VueQriously)
//index.vue
避免重复创建订单,查询已存在的未过期的该用户未支付的同款商品
的订单
@Override
public OrderInfo createOrderByProductId(Long productId) {
OrderInfo orderInfo = getNoPayOrderByProductId(productId);
if (orderInfo != null) {
return orderInfo;
}
orderInfo = new OrderInfo();
orderInfo.setTitle("test")
.setOrderNo(OrderNoUtils.getOrderNo())
.setProductId(productId)
.setTotalFee(1)
.setOrderStatus(OrderStatus.NOTPAY.getType());
save(orderInfo);
return orderInfo;
}
private OrderInfo getNoPayOrderByProductId(Long productId) {
return lambdaQuery().eq(OrderInfo::getProductId, productId)
.eq(OrderInfo::getOrderStatus, OrderStatus.NOTPAY.getType()).one();
}
log.info("生成订单");
OrderInfo orderInfo = orderInfoService.createOrderByProductId(productId);
String codeUrl = orderInfo.getCodeUrl();
if (!StringUtils.isBlank(codeUrl)) {
log.info("订单已存在,二维码已保存");
Map<String, Object> map = new HashMap<>();
map.put("codeUrl", codeUrl);
map.put("orderNo", orderInfo.getOrderNo());
return map;
}
...
//保存二维码
orderInfoService.saveCodeUrl(orderInfo.getOrderNo(), codeUrl);
Map<String, Object> map = new HashMap<>();
HTTP请求方法\n
URL\n
请求时间戳\n
请求随机串\n
请求报文主体\n
绝大多数编程语言提供的签名函数支持对签名数据进行签名。强烈建议商户调用该类函数,使用商户私钥对待签名串进行SHA256 with RSA签名,并对签名结果进行Base64编码
得到签名值
$ echo -n -e \
"GET\n/v3/certificates\n1554208460\n593BEC0C930BF1AFEB40B4A08C8FB242\n\n" \
| openssl dgst -sha256 -sign apiclient_key.pem \
| openssl base64 -A
uOVRnA4qG/MNnYzdQxJanN+zU+lTgIcnU9BxGw5dKjK+VdEUz2FeIoC+D5sB/LN+nGzX3hfZg6r5wT1pl2ZobmIc6p0ldN7J6yDgUzbX8Uk3sD4a4eZVPTBvqNDoUqcYMlZ9uuDdCvNv4TM3c1WzsXUrExwVkI1XO5jCNbgDJ25nkT/c1gIFvqoogl7MdSFGc4W4xZsqCItnqbypR3RuGIlR9h9vlRsy7zJR9PBI83X8alLDIfR1ukt1P7tMnmogZ0cuDY8cZsd8ZlCgLadmvej58SLsIkVxFJ8XyUgx9FmutKSYTmYtWBZ0+tNvfGmbXU7cob8H/4nLBiCwIUFluw==
微信支付商户API v3要求请求通过HTTP Authorization
头来传递签名。 Authorization
由认证类型和签名信息两个部分组成。
下面我们使用命令行演示如何生成签名。
Authorization: 认证类型 签名信息
具体组成为:
1.认证类型,目前为WECHATPAY2-SHA256-RSA2048
2.签名信息
mchid
序列号serial_no
,用于声明所使用的证书nonce_str
timestamp
signature
Authorization
头的示例如下:(注意,示例因为排版可能存在换行,实际数据应在一行)
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900009191",nonce_str="593BEC0C930BF1AFEB40B4A08C8FB242",signature="uOVRnA4qG/MNnYzdQxJanN+zU+lTgIcnU9BxGw5dKjK+VdEUz2FeIoC+D5sB/LN+nGzX3hfZg6r5wT1pl2ZobmIc6p0ldN7J6yDgUzbX8Uk3sD4a4eZVPTBvqNDoUqcYMlZ9uuDdCvNv4TM3c1WzsXUrExwVkI1XO5jCNbgDJ25nkT/c1gIFvqoogl7MdSFGc4W4xZsqCItnqbypR3RuGIlR9h9vlRsy7zJR9PBI83X8alLDIfR1ukt1P7tMnmogZ0cuDY8cZsd8ZlCgLadmvej58SLsIkVxFJ8XyUgx9FmutKSYTmYtWBZ0+tNvfGmbXU7cob8H/4nLBiCwIUFluw==",timestamp="1554208460",serial_no="1DDE55AD98ED71D6EDD4A4A16996DE7B47773A8C"
最终我们可以组一个包含了签名的HTTP请求了。
$ curl https://api.mch.weixin.qq.com/v3/certificates -H 'Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900009191",nonce_str="593BEC0C930BF1AFEB40B4A08C8FB242",signature="uOVRnA4qG/MNnYzdQxJanN+zU+lTgIcnU9BxGw5dKjK+VdEUz2FeIoC+D5sB/LN+nGzX3hfZg6r5wT1pl2ZobmIc6p0ldN7J6yDgUzbX8Uk3sD4a4eZVPTBvqNDoUqcYMlZ9uuDdCvNv4TM3c1WzsXUrExwVkI1XO5jCNbgDJ25nkT/c1gIFvqoogl7MdSFGc4W4xZsqCItnqbypR3RuGIlR9h9vlRsy7zJR9PBI83X8alLDIfR1ukt1P7tMnmogZ0cuDY8cZsd8ZlCgLadmvej58SLsIkVxFJ8XyUgx9FmutKSYTmYtWBZ0+tNvfGmbXU7cob8H/4nLBiCwIUFluw==",timestamp="1554208460",serial_no="1DDE55AD98ED71D6EDD4A4A16996DE7B47773A8C"'
计算签名的示例代码如下。
import okhttp3.HttpUrl;
import java.security.Signature;
import java.util.Base64;
// Authorization:
// GET - getToken("GET", httpurl, "")
// POST - getToken("POST", httpurl, json)
String schema = "WECHATPAY2-SHA256-RSA2048";
HttpUrl httpurl = HttpUrl.parse(url);
String getToken(String method, HttpUrl url, String body) {
String nonceStr = "your nonce string";
long timestamp = System.currentTimeMillis() / 1000;
String message = buildMessage(method, url, timestamp, nonceStr, body);
String signature = sign(message.getBytes("utf-8"));
return "mchid=\"" + yourMerchantId + "\","
+ "nonce_str=\"" + nonceStr + "\","
+ "timestamp=\"" + timestamp + "\","
+ "serial_no=\"" + yourCertificateSerialNo + "\","
+ "signature=\"" + signature + "\"";
}
String sign(byte[] message) {
Signature sign = Signature.getInstance("SHA256withRSA");
sign.initSign(yourPrivateKey);
sign.update(message);
return Base64.getEncoder().encodeToString(sign.sign());
}
String buildMessage(String method, HttpUrl url, long timestamp, String nonceStr, String body) {
String canonicalUrl = url.encodedPath();
if (url.encodedQuery() != null) {
canonicalUrl += "?" + url.encodedQuery();
}
return method + "\n"
+ canonicalUrl + "\n"
+ timestamp + "\n"
+ nonceStr + "\n"
+ body + "\n";
}
如果您的请求返回了签名错误401 Unauthorized,请参考 常见问题之签名相关
如果验证商户的请求签名正确,微信支付会在应答的HTTP头部中包括应答签名。我们建议商户验证应答签名。
同样的,微信支付会在回调的HTTP头部中包括回调报文的签名。商户必须 验证回调的签名,以确保回调是由微信支付发送。
微信支付API v3使用微信支付 的平台私钥(不是商户私钥 )进行应答签名。相应的,商户的技术人员应使用微信支付平台证书中的公钥验签。目前平台证书只提供API进行下载,请参考 获取平台证书列表。
微信支付的平台证书序列号位于HTTP头Wechatpay-Serial
。验证签名前,请商户先检查序列号是否跟商户当前所持有的 微信支付平台证书的序列号一致。如果不一致,请重新获取证书。否则,签名的私钥和证书不匹配,将无法成功验证签名。
首先,商户先从应答中获取以下信息。
Wechatpay-Timestamp
中的应答时间戳。Wechatpay-Nonce
中的应答随机串。然后,请按照以下规则构造应答的验签名串。签名串共有三行,行尾以\n
结束,包括最后一行。\n
为换行符(ASCII编码值为0x0A)。若应答报文主体为空(如HTTP状态码为204 No Content
),最后一行仅为一个\n
换行符。
应答时间戳\n
应答随机串\n
应答报文主体\n
如某个应答的HTTP报文为(省略了ciphertext的具体内容):
HTTP/1.1 200 OK
Server: nginx
Date: Tue, 02 Apr 2019 12:59:40 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 2204
Connection: keep-alive
Keep-Alive: timeout=8
Content-Language: zh-CN
Request-ID: e2762b10-b6b9-5108-a42c-16fe2422fc8a
Wechatpay-Nonce: c5ac7061fccab6bf3e254dcf98995b8c
Wechatpay-Signature: CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA==
Wechatpay-Timestamp: 1554209980
Wechatpay-Serial: 5157F09EFDC096DE15EBE81A47057A7232F1B8E1
Cache-Control: no-cache, must-revalidate
{"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"4de73afd28b6","associated_data":"certificate","ciphertext":"..."}}]}
则验签名串为
1554209980
c5ac7061fccab6bf3e254dcf98995b8c
{"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"4de73afd28b6","associated_data":"certificate","ciphertext":"..."}}]}
微信支付的应答签名通过HTTP头Wechatpay-Signature
传递。(注意,示例因为排版可能存在换行,实际数据应在一行)
Wechatpay-Signature: CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA==
对 Wechatpay-Signature
的字段值使用Base64进行解码,得到应答签名。
某些代理服务器或CDN服务提供商,转发时会“过滤”微信支付扩展的HTTP头,导致应用层无法取到微信支付的签名信息。商户遇到这种情况时,我们建议尝试调整代理服务器配置,或者通过直连的方式访问微信支付的服务器和接收通知。
很多编程语言的签名验证函数支持对验签名串和签名 进行签名验证。强烈建议商户调用该类函数,使用微信支付平台公钥对验签名串和签名进行SHA256 with RSA签名验证。
下面展示使用命令行演示如何进行验签。假设我们已经获取了平台证书并保存为1900009191_wxp_cert.pem
。
首先,从微信支付平台证书导出微信支付平台公钥
$ openssl x509 -in 1900009191_wxp_cert.pem -pubkey -noout > 1900009191_wxp_pub.pem
$ cat 1900009191_wxp_pub.pem
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4zej1cqugGQtVSY2Ah8R
MCKcr2UpZ8Npo+5Ja9xpFPYkWHaF1Gjrn3d5kcwAFuHHcfdc3yxDYx6+9grvJnCA
2zQzWjzVRa3BJ5LTMj6yqvhEmtvjO9D1xbFTA2m3kyjxlaIar/RYHZSslT4VmjIa
tW9KJCDKkwpM6x/RIWL8wwfFwgz2q3Zcrff1y72nB8p8P12ndH7GSLoY6d2Tv0OB
2+We2Kyy2+QzfGXOmLp7UK/pFQjJjzhSf9jxaWJXYKIBxpGlddbRZj9PqvFPTiep
8rvfKGNZF9Q6QaMYTpTp/uKQ3YvpDlyeQlYe4rRFauH3mOE6j56QlYQWivknDX9V
rwIDAQAB
-----END PUBLIC KEY-----
Java支持使用证书初始化签名对象,详见 initVerify(Certificate),并不需要先导出公钥。
然后,把签名base64解码后保存为文件signature.txt
$ openssl base64 -d -A <<< \ 'CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA==' > signature.txt
最后,验证签名 验签名串进行摘要算法
与签名解码后用微信公钥解密
的进行比对
签名生成:摘要->公钥加密->Base64编码
$ openssl dgst -sha256 -verify 1900009191_wxp_pub.pem -signature signature.txt << EOF
1554209980
c5ac7061fccab6bf3e254dcf98995b8c
{"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"d215b0511e9c","associated_data":"certificate","ciphertext":"..."}}]}
EOF
Verified OK
设置authToken
ngrok config add-authtoken 2DEjRSBgc9J5A1oVKs2SU9kZ1h5_4B9NJUkDFACvK218GrESM
启动服务
ngrok http 8090
测试外网访问
你获得的外网地址/api/test
Linux系统命令前要加上./
@PostMapping("/native/notify")
public String nativeNotify(HttpServletRequest request, HttpServletResponse response) {
Gson gson = new Gson();
Map<String, String> map = new HashMap<>();//应答对象
//处理通知参数,将请求体转换为字符串
try {
String body = HttpUtils.readData(request);
log.info("支付通知的完整数据 ===> {}", body);
//验签和解析请求体并解密报文
String decryptData = notificationHandlerUtils.getDecryptData(request, body);
log.info("支付通知解密完的数据 ===> {}", decryptData);
//对订单进行处理
wxPayService.processOrder(decryptData);
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);
}
@Component
public class NotificationHandlerUtils {
@Resource
private Verifier verifier;
@Resource
private WxPayConfig wxPayConfig;
/**
* 验签request并解析加密数据获取订单的详情
* @param request
* @param body
* @return
*/
public String getDecryptData(HttpServletRequest request, String body) {
String serial = request.getHeader(WECHAT_PAY_SERIAL);
String nonce = request.getHeader(WECHAT_PAY_NONCE);
String timestamp = request.getHeader(WECHAT_PAY_TIMESTAMP);
String signature = request.getHeader(WECHAT_PAY_SIGNATURE);
// 构建request,传入必要参数
NotificationRequest req = new NotificationRequest.Builder()
.withSerialNumber(serial)
.withNonce(nonce)
.withTimestamp(timestamp)
.withSignature(signature)
.withBody(body)
.build();
NotificationHandler handler = new NotificationHandler(verifier, wxPayConfig.getApiV3Key().getBytes(StandardCharsets.UTF_8));
// 验签和解析请求体
Notification notification = null;
try {
notification = handler.parse(req);
} catch (ValidationException e) {
throw new RuntimeException("验签失败");
} catch (ParseException e) {
throw new RuntimeException("解析失败");
}
// 从notification中获取解密报文
return notification.getDecryptData();
}
}
/**
* 处理订单
*
* @param decryptData 解密后的报文
*/
@Override
public void processOrder(String decryptData) {
Gson gson = new Gson();
Map<String, Object> decryptDataMap = gson.fromJson(decryptData, HashMap.class);
String orderNo = (String) decryptDataMap.get("out_trade_no");
/*在对业务数据进行状态检查和处理之前,
要采用数据锁进行并发控制,
以避免函数重入造成的数据混乱*/
//尝试获取锁:
// 成功获取则立即返回true,获取失败则立即返回false。不必一直等待锁的释放
if (lock.tryLock()) {
try {
//处理重复通知
//保证接口调用的幂等性:无论接口被调用多少次,产生的结果是一致的
String orderStatus = orderInfoService.getOrderStatus(orderNo);
if (!OrderStatus.NOTPAY.getType().equals(orderStatus)) {
return;
}
//更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);
//记录支付日志
paymentInfoService.createPaymentInfo(decryptData);
} finally {
lock.unlock();
}
}
}
@Override
public void updateStatusByOrderNo(String orderNo, OrderStatus orderStatus) {
log.info("更新订单状态 ===> {}", orderStatus.getType());
LambdaQueryWrapper<OrderInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(OrderInfo::getOrderNo, orderNo);
OrderInfo orderInfo = new OrderInfo().setOrderStatus(orderStatus.getType());
update(orderInfo, wrapper);
}
@Override
public void createPaymentInfo(String decryptData) {
log.info("记录支付日志");
Gson gson = new Gson();
Map<String, Object> decryptDataMap = gson.fromJson(decryptData, HashMap.class);
String orderNo = (String)decryptDataMap.get("out_trade_no");
String transactionId = (String)decryptDataMap.get("transaction_id");
String tradeType = (String)decryptDataMap.get("trade_type");
String tradeState = (String)decryptDataMap.get("trade_state");
Map<String, Object> amount = (Map)decryptDataMap.get("amount");
Integer payerTotal = ((Double) amount.get("payer_total")).intValue();
PaymentInfo paymentInfo = new PaymentInfo();
paymentInfo.setOrderNo(orderNo)
.setPaymentType(PayType.WXPAY.getType())
.setTransactionId(transactionId)
.setTradeType(tradeType)
.setTradeState(tradeState)
.setPayerTotal(payerTotal)
.setContent(decryptData);
save(paymentInfo);
}
适用对象: 直连商户
请求URL: https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}/close
请求方式: POST
path指该参数为路径参数
query指该参数需在请求URL传参
body指该参数需在请求JSON传参
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
直连商户号 | mchid | string[1,32] | 是 | body 直连商户的商户号,由微信支付生成并下发。 示例值:1230000109 |
商户订单号 | out_trade_no | string[6,32] | 是 | path 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一 示例值:1217752501201407033233368018 |
请求示例
{
"mchid": "1230000109"
}
@ApiOperation("用户取消订单")
@PostMapping("/cancel/{orderNo}")
public R cancel(@PathVariable String orderNo) throws IOException {
log.info("取消订单");
wxPayService.cancelOrder(orderNo);
return R.ok().setMessage("订单已取消");
}
@Override
public void cancelOrder(String orderNo) throws IOException {
//调用微信支付的关单接口
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);//取消订单url
//组装json请求体
Gson gson = new Gson();
Map<String, String> paramsMap = new HashMap();
paramsMap.put("mchid", wxPayConfig.getMchId());
//将参数转换成json字符串
String jsonParams = gson.toJson(paramsMap);
HttpPost httpPost = generateHttpPost(jsonParams, url);
//完成签名并执行请求
CloseableHttpResponse response = wxPayClient.execute(httpPost);
int statusCode = response.getStatusLine().getStatusCode();
try {
if (statusCode == 200) { //处理成功
log.info("成功200");
} else if (statusCode == 204) { //处理成功,无返回Body
log.info("成功204");
} else {
log.info("取消订单失败,响应码 = " + statusCode);
throw new IOException("request failed");
}
} finally {
response.close();
}
}
适用对象: 直连商户
请求URL: https://api.mch.weixin.qq.com/v3/pay/transactions/id/{transaction_id}
**请求方式:**GET
path指该参数为路径参数
query指该参数需在请求URL传参
body指该参数需在请求JSON传参
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
直连商户号 | mchid | string[1,32] | 是 | query 直连商户的商户号,由微信支付生成并下发。 示例值:1230000109 |
微信支付订单号 | transaction_id | string[1,32] | 是 | path 微信支付系统生成的订单号 示例值:1217752501201407033233368018 |
查询订单
适用对象: 直连商户
请求URL: https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}
**请求方式:**GET
path指该参数为路径参数
query指该参数需在请求URL传参
body指该参数需在请求JSON传参
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
直连商户号 | mchid | string[1,32] | 是 | query 直连商户的商户号,由微信支付生成并下发。 示例值:1230000109 |
商户订单号 | out_trade_no | string[6,32] | 是 | path 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一。 特殊规则:最小字符长度为6 示例值:1217752501201407033233368018 |
https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/1217752501201407033233368018?mchid=1230000109
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
应用ID | appid | string[1,32] | 是 | 直连商户申请的公众号或移动应用appid。 示例值:wxd678efh567hg6787 |
直连商户号 | mchid | string[1,32] | 是 | 直连商户的商户号,由微信支付生成并下发。 示例值:1230000109 |
商户订单号 | out_trade_no | string[6,32] | 是 | 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一,详见【商户订单号】。 示例值:1217752501201407033233368018 |
微信支付订单号 | transaction_id | string[1,32] | 否 | 微信支付系统生成的订单号。 示例值:1217752501201407033233368018 |
交易类型 | trade_type | string[1,16] | 否 | 交易类型,枚举值: JSAPI:公众号支付 NATIVE:扫码支付 APP:APP支付 MICROPAY:付款码支付 MWEB:H5支付 FACEPAY:刷脸支付 示例值:MICROPAY |
交易状态 | trade_state | string[1,32] | 是 | 交易状态,枚举值: SUCCESS:支付成功 REFUND:转入退款 NOTPAY:未支付 CLOSED:已关闭 REVOKED:已撤销(仅付款码支付会返回) USERPAYING:用户支付中(仅付款码支付会返回) PAYERROR:支付失败(仅付款码支付会返回) 示例值:SUCCESS |
交易状态描述 | trade_state_desc | string[1,256] | 是 | 交易状态描述 示例值:支付成功 |
付款银行 | bank_type | string[1,32] | 否 | 银行类型,采用字符串类型的银行标识。银行标识请参考《银行类型对照表》 示例值:CMC |
附加数据 | attach | string[1,128] | 否 | 附加数据,在查询API和支付通知中原样返回,可作为自定义参数使用,实际情况下只有支付完成状态才会返回该字段。 示例值:自定义数据 |
支付完成时间 | success_time | string[1,64] | 否 | 支付完成时间,遵循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年5月20日 13点29分35秒。 示例值:2018-06-08T10:34:56+08:00 |
+支付者 | payer | object | 是 | 支付者信息 |
+订单金额 | amount | object | 否 | 订单金额信息,当支付成功时返回该字段。 |
+场景信息 | scene_info | object | 否 | 支付场景描述 |
+优惠功能 | promotion_detail | array | 否 | 优惠功能,享受优惠时返回该字段。 |
{
"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"
}
/**
* 根据订单号查询微信支付查单接口
* 响应体的明文
* @param orderNo
* @return
* @throws IOException
*/
@Override
public String queryOrder(String orderNo) throws IOException {
log.info("查单接口调用 ===> {}", orderNo);
//组装请求URL
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) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("查询订单失败,响应码 = " + statusCode + ",返回结果 = " + bodyAsString);
throw new IOException("request failed");
}
return bodyAsString;
} finally {
response.close();
}
}
@Component
@Slf4j
public class WxPayTask {
@Resource
private OrderInfoService orderInfoService;
@Resource
private WxPayService wxPayService;
/**
* 从第0秒开始每隔30秒执行1次,查询创建超过5分钟,并且未支付的订单
*/
@Scheduled(cron = "0/30 * * * * ?")
public void orderConfirm() throws IOException {
log.info("确认超时订单的状态...");
//获取超时未支付的订单
List<OrderInfo> orderInfoList = orderInfoService.getNoPayOrderByDurations(5);
for (OrderInfo orderInfo : orderInfoList) {
String orderNo = orderInfo.getOrderNo();
log.warn("超时订单 ===> {}", orderNo);
//核实订单状态:调用微信支付主动查询订单
wxPayService.checkOrderStatus(orderNo);
}
}
}
/**
* 从第0秒开始每隔30秒执行1次,查询创建超过5分钟,并且未支付的订单
*
* @param minutes
* @return
*/
@Override
public List<OrderInfo> getNoPayOrderByDurations(int minutes) {
//minutes分钟之前的时间
Instant instant = Instant.now().minus(Duration.ofMinutes(minutes));
List<OrderInfo> orderInfoList = lambdaQuery().eq(OrderInfo::getOrderStatus, OrderStatus.NOTPAY.getType())
.le(OrderInfo::getCreateTime, instant)
.list();
return orderInfoList;
}
/**
* 根据订单号查询微信支付查单接口,核实订单状态
* 如果订单已支付,则更新商户端订单状态,并记录支付日志
* 如果订单未支付,则调用关单接口关闭订单,并更新商户端订单状态
*
* @param orderNo 创建5分钟且未支付的订单号
*/
@Override
public void checkOrderStatus(String orderNo) throws IOException {
log.warn("根据订单号核实订单状态 ===> {}", orderNo);
Gson gson = new Gson();
//查单
String bodyAsString = queryOrder(orderNo);
Map<String, Object> bodyMap = gson.fromJson(bodyAsString, HashMap.class);
String trade_state = (String) bodyMap.get("trade_state");
if (WxTradeState.SUCCESS.getType().equals(trade_state)) {
log.warn("核实订单已支付 ===> {}", orderNo);
//如果确认订单已支付则更新本地订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);
//记录支付日志
paymentInfoService.createPaymentInfo(bodyAsString);
} else if (WxTradeState.NOTPAY.getType().equals(trade_state)) {
log.warn("核实订单未支付 ===> {}", orderNo);
//关单接口
closeOrder(orderNo);
//更新为超时关闭
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.CLOSED);
}
}
退款状态转变如下:
**适用对象:**直连商户
**请求URL:**https://api.mch.weixin.qq.com/v3/refund/domestic/refunds
**请求方式:**POST
**接口频率:**150qps
path 指该参数为路径参数
query 指该参数为URL参数
body 指该参数需在请求JSON传参
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
微信支付订单号 | transaction_id | string[1, 32] | 二选一 | body原支付交易对应的微信订单号 示例值:1217752501201407033233368018 |
商户订单号 | out_trade_no | string[6, 32] | body原支付交易对应的商户订单号 示例值:1217752501201407033233368018 | |
商户退款单号 | out_refund_no | string[1, 64] | 是 | body商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。 示例值:1217752501201407033233368018 |
退款原因 | reason | string[1, 80] | 否 | body若商户传入,会在下发给用户的退款消息中体现退款原因 示例值:商品已售完 |
退款结果回调url | notify_url | string[8, 256] | 否 | body异步接收微信支付退款结果通知的回调地址,通知url必须为外网可访问的url,不能携带参数。 如果参数中传了notify_url,则商户平台上配置的回调地址将不会生效,优先回调当前传的这个地址。 示例值:https://weixin.qq.com |
退款资金来源 | funds_account | string[1,32] | 否 | body若传递此参数则使用对应的资金账户退款,否则默认使用未结算资金退款(仅对老资金流商户适用) 枚举值: AVAILABLE:可用余额账户 示例值:AVAILABLE |
+金额信息 | amount | object | 是 | body订单金额信息 |
+退款商品 | goods_detail | array | 否 | body指定商品退款需要传此参数,其他场景无需传递 |
{
"transaction_id": "1217752501201407033233368018",
"out_refund_no": "1217752501201407033233368018",
"reason": "商品已售完",
"notify_url": "https://weixin.qq.com",
"funds_account": "AVAILABLE",
"amount": {
"refund": 888,
"from": [
{
"account": "AVAILABLE",
"amount": 444
}
],
"total": 888,
"currency": "CNY"
},
"goods_detail": [
{
"merchant_goods_id": "1217752501201407033233368018",
"wechatpay_goods_id": "1001",
"goods_name": "iPhone6s 16G",
"unit_price": 528800,
"refund_amount": 528800,
"refund_quantity": 1
}
]
}
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
微信支付退款单号 | refund_id | string[1, 32] | 是 | 微信支付退款单号 示例值:50000000382019052709732678859 |
商户退款单号 | out_refund_no | string[1, 64] | 是 | 商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。 示例值:1217752501201407033233368018 |
微信支付订单号 | transaction_id | string[1, 32] | 是 | 微信支付交易订单号 示例值:1217752501201407033233368018 |
商户订单号 | out_trade_no | string[1, 32] | 是 | 原支付交易对应的商户订单号 示例值:1217752501201407033233368018 |
退款渠道 | channel | string[1, 16] | 是 | 枚举值: ORIGINAL:原路退款 BALANCE:退回到余额 OTHER_BALANCE:原账户异常退到其他余额账户 OTHER_BANKCARD:原银行卡异常退到其他银行卡 示例值:ORIGINAL |
退款入账账户 | user_received_account | string[1, 64] | 是 | 取当前退款单的退款入账方,有以下几种情况: 1)退回银行卡:{银行名称}{卡类型}{卡尾号} 2)退回支付用户零钱:支付用户零钱 3)退还商户:商户基本账户商户结算银行账户 4)退回支付用户零钱通:支付用户零钱通 示例值:招商银行信用卡0403 |
退款成功时间 | success_time | string[1, 64] | 否 | 退款成功时间,当退款状态为退款成功时有返回。 示例值:2020-12-01T16:18:12+08:00 |
退款创建时间 | create_time | string[1, 64] | 是 | 退款受理时间 示例值:2020-12-01T16:18:12+08:00 |
退款状态 | status | string[1, 32] | 是 | 退款到银行发现用户的卡作废或者冻结了,导致原路退款银行卡失败,可前往商户平台-交易中心,手动处理此笔退款。 枚举值: SUCCESS:退款成功 CLOSED:退款关闭 PROCESSING:退款处理中 ABNORMAL:退款异常 示例值:SUCCESS |
资金账户 | funds_account | string[1, 32] | 否 | 退款所使用资金对应的资金账户类型 枚举值: UNSETTLED : 未结算资金 AVAILABLE : 可用余额 UNAVAILABLE : 不可用余额 OPERATION : 运营户 BASIC : 基本账户(含可用余额和不可用余额) 示例值:UNSETTLED |
+金额信息 | amount | object | 是 | 金额详细信息 |
+优惠退款信息 | promotion_detail | array | 否 | 优惠退款信息 |
{
"refund_id": "50000000382019052709732678859",
"out_refund_no": "1217752501201407033233368018",
"transaction_id": "1217752501201407033233368018",
"out_trade_no": "1217752501201407033233368018",
"channel": "ORIGINAL",
"user_received_account": "招商银行信用卡0403",
"success_time": "2020-12-01T16:18:12+08:00",
"create_time": "2020-12-01T16:18:12+08:00",
"status": "SUCCESS",
"funds_account": "UNSETTLED",
"amount": {
"total": 100,
"refund": 100,
"from": [
{
"account": "AVAILABLE",
"amount": 444
}
],
"payer_total": 90,
"payer_refund": 90,
"settlement_refund": 100,
"settlement_total": 100,
"discount_refund": 10,
"currency": "CNY"
},
"promotion_detail": [
{
"promotion_id": "109519",
"scope": "SINGLE",
"type": "DISCOUNT",
"amount": 5,
"refund_amount": 100,
"goods_detail": [
{
"merchant_goods_id": "1217752501201407033233368018",
"wechatpay_goods_id": "1001",
"goods_name": "iPhone6s 16G",
"unit_price": 528800,
"refund_amount": 528800,
"refund_quantity": 1
}
]
}
]
}
/**
* 创建退款订单
* @param orderNo
* @param reason
* @return
*/
@Override
public RefundInfo createRefundByOrderNo(String orderNo, String reason) {
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
RefundInfo refundInfo = new RefundInfo();
refundInfo.setOrderNo(orderNo)
.setRefundNo(OrderNoUtils.getRefundNo())
.setTotalFee(orderInfo.getTotalFee())//原金额
.setRefund(orderInfo.getTotalFee())//退款金额
.setReason(reason);
save(refundInfo);
return refundInfo;
}
@Override
public OrderInfo getOrderByOrderNo(String orderNo) {
return lambdaQuery().eq(OrderInfo::getOrderNo, orderNo).one();
}
private String generateJsonParamsOfRefund(RefundInfo refundInfo) {
Gson gson = new Gson();
Map<String, Object> paramsMap = new HashMap<>();
paramsMap.put("out_trade_no", refundInfo.getOrderNo());//商品订单号
paramsMap.put("out_refund_no", refundInfo.getRefundNo());//退款单号
paramsMap.put("reason", refundInfo.getReason());
paramsMap.put("notify_url", wxPayConfig.getNotifyDomain().concat(WxNotifyType.REFUND_NOTIFY.getType()));
Map<String, Object> amountMap = new HashMap<>();
amountMap.put("refund", refundInfo.getRefund());//退款金额
amountMap.put("total", refundInfo.getTotalFee());//原订单金额
amountMap.put("currency", "CNY");
paramsMap.put("amount", amountMap);
//将参数转换成json字符串
return gson.toJson(paramsMap);
}
/**
* 申请退款
*
* @param orderNo
* @param reason
*/
@Override
public void refund(String orderNo, String reason) throws IOException {
log.info("创建退款单记录");
RefundInfo refundInfo = refundInfoService.createRefundByOrderNo(orderNo, reason);
log.info("调用退款API");
String url = wxPayConfig.getDomain().concat(WxApiType.DOMESTIC_REFUNDS.getType());
//json请求体
String jsonParams = generateJsonParamsOfRefund(refundInfo);
HttpPost httpPost = generateHttpPost(jsonParams, url);
//完成签名并执行请求
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) { //处理成功,无返回Body
log.info("成功");
} else {
throw new RuntimeException("退款失败,响应码 = " + statusCode + ",退款返回结果 = " + bodyAsString);
}
//更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_PROCESSING);
//更新退款单
refundInfoService.updateRefund(bodyAsString);
} finally {
response.close();
}
}
/**
* 记录退款记录
* @param content
*/
@Override
public void updateRefund(String content) {
//将json字符串转换成Map
Gson gson = new Gson();
Map<String, String> resultMap = gson.fromJson(content, HashMap.class);
//根据退款单编号修改退款单
LambdaQueryWrapper<RefundInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(RefundInfo::getRefundNo, resultMap.get("out_refund_no"));
RefundInfo refundInfo = new RefundInfo();
refundInfo.setRefundId(resultMap.get("refund_id"));
//1、查询退款和申请退款中的返回参数
if (resultMap.get("status") != null) {
refundInfo.setRefundStatus(resultMap.get("status"));
refundInfo.setContentReturn(content);
}
//2、退款回调中的回调参数
if (resultMap.get("refund_status") != null) {
refundInfo.setRefundStatus(resultMap.get("refund_status"));
refundInfo.setContentNotify(content);
}
update(refundInfo,wrapper);
}
**适用对象:**直连商户
**请求URL:**https://api.mch.weixin.qq.com/v3/refund/domestic/refunds/{out_refund_no}
**请求方式:**GET
path 指该参数为路径参数
query 指该参数为URL参数
body 指该参数需在请求JSON传参
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
商户退款单号 | out_refund_no | string[1, 64] | 是 | path商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。 示例值:1217752501201407033233368018 |
https://api.mch.weixin.qq.com/v3/refund/domestic/refunds/1217752501201407033233368018
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
微信支付退款单号 | refund_id | string[1, 32] | 是 | 微信支付退款单号 示例值:50000000382019052709732678859 |
商户退款单号 | out_refund_no | string[1, 64] | 是 | 商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。 示例值:1217752501201407033233368018 |
微信支付订单号 | transaction_id | string[1, 32] | 是 | 微信支付交易订单号 示例值:1217752501201407033233368018 |
商户订单号 | out_trade_no | string[1, 32] | 是 | 原支付交易对应的商户订单号 示例值:1217752501201407033233368018 |
退款渠道 | channel | string[1, 16] | 是 | 枚举值: ORIGINAL:原路退款 BALANCE:退回到余额 OTHER_BALANCE:原账户异常退到其他余额账户 OTHER_BANKCARD:原银行卡异常退到其他银行卡 示例值:ORIGINAL |
退款入账账户 | user_received_account | string[1, 64] | 是 | 取当前退款单的退款入账方,有以下几种情况: 1)退回银行卡:{银行名称}{卡类型}{卡尾号} 2)退回支付用户零钱:支付用户零钱 3)退还商户:商户基本账户商户结算银行账户 4)退回支付用户零钱通:支付用户零钱通 示例值:招商银行信用卡0403 |
退款成功时间 | success_time | string[1, 64] | 否 | 退款成功时间,当退款状态为退款成功时有返回。 示例值:2020-12-01T16:18:12+08:00 |
退款创建时间 | create_time | string[1, 64] | 是 | 退款受理时间 示例值:2020-12-01T16:18:12+08:00 |
退款状态 | status | string[1, 32] | 是 | 款到银行发现用户的卡作废或者冻结了,导致原路退款银行卡失败,可前往商户平台-交易中心,手动处理此笔退款。 枚举值: SUCCESS:退款成功 CLOSED:退款关闭 PROCESSING:退款处理中 ABNORMAL:退款异常 示例值:SUCCESS |
资金账户 | funds_account | string[1, 32] | 否 | 退款所使用资金对应的资金账户类型 枚举值: UNSETTLED : 未结算资金 AVAILABLE : 可用余额 UNAVAILABLE : 不可用余额 OPERATION : 运营户 BASIC : 基本账户(含可用余额和不可用余额) 示例值:UNSETTLED |
+金额信息 | amount | object | 是 | 金额详细信息 |
+优惠退款信息 | promotion_detail | array | 否 | 优惠退款信息 |
{
"refund_id": "50000000382019052709732678859",
"out_refund_no": "1217752501201407033233368018",
"transaction_id": "1217752501201407033233368018",
"out_trade_no": "1217752501201407033233368018",
"channel": "ORIGINAL",
"user_received_account": "招商银行信用卡0403",
"success_time": "2020-12-01T16:18:12+08:00",
"create_time": "2020-12-01T16:18:12+08:00",
"status": "SUCCESS",
"funds_account": "UNSETTLED",
"amount": {
"total": 100,
"refund": 100,
"from": [
{
"account": "AVAILABLE",
"amount": 444
}
],
"payer_total": 90,
"payer_refund": 90,
"settlement_refund": 100,
"settlement_total": 100,
"discount_refund": 10,
"currency": "CNY"
},
"promotion_detail": [
{
"promotion_id": "109519",
"scope": "SINGLE",
"type": "DISCOUNT",
"amount": 5,
"refund_amount": 100,
"goods_detail": [
{
"merchant_goods_id": "1217752501201407033233368018",
"wechatpay_goods_id": "1001",
"goods_name": "iPhone6s 16G",
"unit_price": 528800,
"refund_amount": 528800,
"refund_quantity": 1
}
]
}
]
}
/**
* 从第0秒开始每隔30秒执行1次,查询创建超过5分钟,并且未成功的退款单
*/
@Scheduled(cron = "0/30 * * * * ?")
public void refundConfirm() throws IOException {
log.info("确认超时退款订单的状态...");
//获取超时退款仍在处理中的订单
List<RefundInfo> RefundInfoList = refundInfoService.getNoPayOrderByDurations(5);
for (RefundInfo refundInfo : RefundInfoList) {
String refundNo = refundInfo.getRefundNo();
log.warn("超时未退款的退款单号 ===> {}", refundNo);
//核实订单状态:调用微信支付查询退款接口
wxPayService.checkRefundStatus(refundNo);
}
}
/**
* 找出申请退款超过minutes分钟并且未成功的退款单
* @param minutes
* @return
*/
@Override
public List<RefundInfo> getNoPayOrderByDurations(int minutes) {
//minutes分钟之前的时间
Instant instant = Instant.now().minus(Duration.ofMinutes(minutes));
List<RefundInfo> RefundInfoList = lambdaQuery().eq(RefundInfo::getRefundStatus, WxRefundStatus.PROCESSING.getType())
.le(RefundInfo::getCreateTime, instant)
.list();
return RefundInfoList;
}
/**
* 1.调用查询退款记录接口
* 2.更新数据库
* @param refundNo
* @throws IOException
*/
@Override
public void checkRefundStatus(String refundNo) throws IOException {
log.warn("根据退款订单号核实退款订单状态 ===> {}", refundNo);
Gson gson = new Gson();
//查询退款订单
String bodyAsString = queryRefund(refundNo);
Map<String, String> bodyMap = gson.fromJson(bodyAsString, HashMap.class);
String status = bodyMap.get("status");
String orderNo = bodyMap.get("out_trade_no");
if (WxRefundStatus.SUCCESS.getType().equals(status)) {
log.warn("核实订单退款成功 ===> {}", refundNo);
//如果确认订单已退款成功则更新本地订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_SUCCESS);
} else if (WxRefundStatus.ABNORMAL.getType().equals(status)) {
log.warn("核实订单退款异常 ===> {}", refundNo);
//如果确认订单退款异常则更新本地订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_ABNORMAL);
}
//保存响应体到数据库中
refundInfoService.updateRefund(bodyAsString);
}
/**
* 调用查询退款单接口,核实订单状态
* @param refundNo
* @return
* @throws IOException
*/
@Override
public String queryRefund(String refundNo) throws IOException {
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) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("查询退款失败,响应码 = " + statusCode + ",查询退款返回结果 = " + bodyAsString);
throw new IOException("request failed");
}
return bodyAsString;
} finally {
response.close();
}
}
**适用对象:**直连商户
**请求方式:**POST
**请求URL:**该链接是通过[申请退款接口]指定的notify_url,必须为https协议。如果链接无法访问,商户将无法接收到微信通知。 通知url必须为直接可访问的url,不能携带参数。示例:“https://pay.weixin.qq.com/wxpay/pay.action”
商户退款完成后,微信会把相关退款结果和用户信息发送给清算机构,清算机构需要接收处理后返回应答成功,然后继续给异步通知到下游从业机构。
对后台通知交互时,如果微信收到应答不是成功或超时,微信认为通知失败,微信会通过一定的策略定期重新发起通知,尽可能提高通知的成功率,但微信不保证通知最终能成功。(通知频率为15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h - 总计 24h4m)
退款结果通知是以POST 方法访问商户设置的通知url,通知的数据以JSON 格式通过请求主体(BODY)传输。通知的数据包括了加密的支付结果详情
(注:由于涉及到回调加密和解密,商户必须先设置好apiv3密钥后才能解密回调通知,apiv3密钥设置文档指引详见APIv3密钥设置指引)
下面详细描述对通知数据进行解密的流程:
注: AEAD_AES_256_GCM算法的接口细节,请参考rfc5116。微信支付使用的密钥key长度为32个字节,随机串nonce长度12个字节,associated_data长度小于16个字节并可能为空字符串。
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
通知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年5月20日13点29分35秒。 示例值:2018-06-08T10:34:56+08:00 |
通知类型 | event_type | string[1,32] | 是 | 通知的类型: REFUND.SUCCESS:退款成功通知 REFUND.ABNORMAL:退款异常通知 REFUND.CLOSED:退款关闭通知 示例值:REFUND.SUCCESS |
通知简要说明 | summary | string[1,16] | 是 | 通知简要说明 示例值:退款成功 |
通知数据类型 | resource_type | string[1,32] | 是 | 通知的资源数据类型,支付成功通知为encrypt-resource 示例值:encrypt-resource |
+通知数据 | resource | object | 是 | 通知资源数据 json格式,见示例 |
加密不能保证通知请求来自微信。微信会对发送给商户的通知进行签名,并将签名值放在通知的HTTP头Wechatpay-Signature。商户应当验证签名,以确认请求来自微信,而不是其他的第三方。签名验证的算法请参考 《微信支付API v3签名验证》。
{
"id":"EV-2018022511223320873",
"create_time":"2018-06-08T10:34:56+08:00",
"resource_type":"encrypt-resource",
"event_type":"REFUND.SUCCESS",
"summary":"退款成功",
"resource" : {
"original_type": "refund",
"algorithm":"AEAD_AES_256_GCM",
"ciphertext": "...",
"associated_data": "",
"nonce": "..."
}
}
{
"mchid": "1900000100",
"transaction_id": "1008450740201411110005820873",
"out_trade_no": "20150806125346",
"refund_id": "50200207182018070300011301001",
"out_refund_no": "7752501201407033233368018",
"refund_status": "SUCCESS",
"success_time": "2018-06-08T10:34:56+08:00",
"user_received_account": "招商银行信用卡0403",
"amount" : {
"total": 999,
"refund": 999,
"payer_total": 999,
"payer_refund": 999
}
}
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
直连商户号 | mchid | string[1,32] | 是 | 直连商户的商户号,由微信支付生成并下发。 示例值:1900000100 |
商户订单号 | out_trade_no | string[1,32] | 是 | 返回的商户订单号 示例值: 1217752501201407033233368018 |
微信支付订单号 | transaction_id | string[1,32] | 是 | 微信支付订单号 示例值: 1217752501201407033233368018 |
商户退款单号 | out_refund_no | string[1,64] | 是 | 商户退款单号 示例值: 1217752501201407033233368018 |
微信支付退款单号 | refund_id | string[1,32] | 是 | 微信退款单号 示例值: 1217752501201407033233368018 |
退款状态 | refund_status | string[1,16] | 是 | 退款状态,枚举值: SUCCESS:退款成功 CLOSED:退款关闭 ABNORMAL:退款异常,退款到银行发现用户的卡作废或者冻结了,导致原路退款银行卡失败,可前往【商户平台—>交易中心】,手动处理此笔退款 示例值:SUCCESS |
退款成功时间 | success_time | string[1,64] | 否 | 1、退款成功时间,遵循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年5月20日13点29分35秒。 2、当退款状态为退款成功时返回此参数。 示例值:2018-06-08T10:34:56+08:00 |
退款入账账户 | user_received_account | string[1,64] | 是 | 取当前退款单的退款入账方。 1、退回银行卡:{银行名称}{卡类型}{卡尾号} 2、退回支付用户零钱: 支付用户零钱 3、退还商户: 商户基本账户、商户结算银行账户 4、退回支付用户零钱通:支付用户零钱通 示例值:招商银行信用卡0403 |
+金额信息 | amount | object | 是 | 金额信息 |
**接收成功:**HTTP应答状态码需返回200或204,无需返回应答报文。
**接收失败:**HTTP应答状态码需返回5XX或4XX,同时需返回应答报文,格式如下:
**注意:**重试过多会导致微信支付端积压过多通知而堵塞,影响其他正常通知。
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
返回状态码 | code | string[1,32] | 是 | 错误码,SUCCESS为接收成功,其他错误码为失败 示例值:SUCCESS |
返回信息 | message | string[1,256] | 否 | 返回信息,如非空,为错误原因 示例值:系统错误 |
{
"code": "SUCCESS"
}
/**
* 退款结果通知
* 退款状态改变后,微信会把相关退款结果发送给商户
*/
@PostMapping("/refunds/notify")
public String refundsNotify(HttpServletRequest request, HttpServletResponse response) throws Exception {
log.info("退款通知执行");
Gson gson = new Gson();
Map<String, String> map = new HashMap<>();//应答对象
//处理通知参数,将请求体转换为字符串
try {
String body = HttpUtils.readData(request);
log.info("退款通知的完整数据 ===> {}", body);
//验签和解析请求体并解密报文
String decryptData = notificationHandlerUtils.getDecryptData(request, body);
log.info("退款通知解密完的数据 ===> {}", decryptData);
//对订单进行处理
wxPayService.processRefund(decryptData);
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);
}
}
/**
* 处理退款通知
* @param decryptData 解密后的明文
*/
@Override
public void processRefund(String decryptData) {
Gson gson = new Gson();
Map<String, Object> decryptDataMap = gson.fromJson(decryptData, HashMap.class);
String orderNo = (String) decryptDataMap.get("out_trade_no");
/*在对业务数据进行状态检查和处理之前,
要采用数据锁进行并发控制,
以避免函数重入造成的数据混乱*/
//尝试获取锁:
// 成功获取则立即返回true,获取失败则立即返回false。不必一直等待锁的释放
if (lock.tryLock()) {
try {
//处理重复通知
//保证接口调用的幂等性:无论接口被调用多少次,产生的结果是一致的
String orderStatus = orderInfoService.getOrderStatus(orderNo);
//申请退款后的OrderStatus == REFUND_PROCESSING
if (!OrderStatus.REFUND_PROCESSING.getType().equals(orderStatus)) {
return;
}
//更新订单状态 -- 退款成功
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_SUCCESS);
//将回调通知的请求体存入数据库
refundInfoService.updateRefund(decryptData);
} finally {
lock.unlock();
}
}
}
/**
* 申请账单
*
* @param billDate
* @param type
* @return
*/
@Override
public String queryBill(String billDate, String type) throws IOException {
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) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("申请账单异常,响应码 = " + statusCode + ",申请账单异常返回结果 = " + bodyAsString);
throw new IOException("request failed");
}
//获取账单下载地址
Gson gson = new Gson();
Map<String, String> resultMap = gson.fromJson(bodyAsString, HashMap.class);
return resultMap.get("download_url");
} finally {
response.close();
}
/**
* 获取 HttpClient 对象 无需进行应答签名验证,跳过验签的流程
*
* @return
*/
@Bean(name = "wxPayNoSignClient")
public CloseableHttpClient getWxPayNoSignClient() {
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
//设置商户信息
.withMerchant(mchId, mchSerialNo, getPrivateKey(privateKeyPath))
//无需进行签名验证、通过withValidator((response) -> true)实现
.withValidator((response) -> true);
// 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签,并进行证书自动更新
CloseableHttpClient httpClient = builder.build();
return httpClient;
}
/**
* 下载账单
* @param billDate
* @param type
* @return
*/
@Override
public String downloadBill(String billDate, String type) throws IOException {
log.warn("下载账单接口调用 {}, {}", billDate, type);
String downloadUrl = queryBill(billDate, type);
HttpGet httpGet = new HttpGet(downloadUrl);
httpGet.addHeader("Accept", "application/json");
//完成签名并执行请求,无需验签响应结果
CloseableHttpResponse response = wxPayNoSignClient.execute(httpGet);
# 接入微信支付
## 接入指引
### 获取商户号
[微信商户平台](https://pay.weixin.qq.com/):https://pay.weixin.qq.com/
场景:Native支付
步骤:提交资料 => 签署协议 => 获取商户
### 获取APPID
[微信公众平台](https://mp.weixin.qq.com/):https://mp.weixin.qq.com/
步骤:注册服务号 => 服务号认证 => 获取APPID => 绑定商户号
### 获取APIv3密钥
APIv3版本的接口需要此秘钥
步骤:登录商户平台 => 选择账户中心 => 安全中心 => API安全 => 设置APIv3密钥
[随机密码生成工具](https://suijimimashengcheng.bmcx.com/):https://suijimimashengcheng.bmcx.com/
### 申请商户API证书
APIv3版本的所有接口都需要;APIv2版本的高级接口需要(如:退款、企业红包、企业付款等)
步骤:登录商户平台 => 选择账户中心 => 安全中心 => API安全 => 申请API证书 => 妥善管理
![image-20220923132557280](https://gitee.com/xzxwbb/cloud-image/raw/master/img/image-20220923132557280.png)
### 获取微信平台证书
通过编程动态获取,后续有教程
### demo仓库地址
https://gitee.com/xzxwbb/wxpayment-demo
### 参考微信支付
[【尚硅谷】微信支付&支付宝支付,一套搞定Java在线支付开发教程](https://www.bilibili.com/video/BV1US4y1D77m?share_source=copy_web&vd_source=b8915732f82f77d0c66288af3bcb3b1d)
demo中的私钥文件,商户的参数由上面的视频资料提供
## 加载商户私钥
### 将私钥文件复制到项目根目录下:
![image-20220923132744453](https://gitee.com/xzxwbb/cloud-image/raw/master/img/image-20220923132744453.png)
### 引入SDK
[官方提供的SDK仓库,帮助我们完成开发。实现了请求签名的生成和应答签名的验证。](https://github.com/wechatpay-apiv3/wechatpay-apache-httpclient)
```xml
<!--微信支付SDK 最新版本-->
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-apache-httpclient</artifactId>
<version>0.4.8</version>
</dependency>
# 示例:私钥存储在文件
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(
new FileInputStream("/path/to/apiclient_key.pem"));
# 示例:私钥为String字符串
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(
new ByteArrayInputStream(privateKey.getBytes("utf-8")));
通过第三方的工具来实现商户私钥签名和微信公钥验签的复杂工作
/**
* 获取签名验证器
*
* @return
*/
@Bean
public Verifier getVerifier() throws Exception {
// 获取证书管理器实例
CertificatesManager certificatesManager = CertificatesManager.getInstance();
// 向证书管理器增加需要自动更新平台证书的商户信息
certificatesManager.putMerchant(
mchId,
new WechatPay2Credentials(mchId, new PrivateKeySigner(mchSerialNo, getPrivateKey(privateKeyPath))),
//对称加密的密钥
apiV3Key.getBytes(StandardCharsets.UTF_8));
// ... 若有多个商户号,可继续调用putMerchant添加商户信息
// 从证书管理器中获取verifier
Verifier verifier = certificatesManager.getVerifier(mchId);
return verifier;
}
/**
* 获取 HttpClient 对象
*
* @param verifier 签名验证器
* @return
*/
@Bean(name = "wxPayClient")
public CloseableHttpClient getWxPayClient(Verifier verifier) {
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
.withMerchant(mchId, mchSerialNo, getPrivateKey(privateKeyPath))
.withValidator(new WechatPay2Validator(verifier));
// ... 接下来,你仍然可以通过builder设置各种参数,来配置你的HttpClient
// 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签,并进行证书自动更新
CloseableHttpClient httpClient = builder.build();
return httpClient;
}
/**
* 生成订单
* @param productId
* @return
*/
private OrderInfo generateOrderInfo(Long productId) {
OrderInfo orderInfo = new OrderInfo();
orderInfo.setTitle("test")
.setOrderNo(OrderNoUtils.getOrderNo())
.setProductId(productId)
.setTotalFee(1)
.setOrderStatus(OrderStatus.NOTPAY.getType());
return orderInfo;
}
/**
* 生成请求json
* @param orderInfo
* @return
*/
private String generateJsonParams(OrderInfo orderInfo) {
Gson gson = new Gson();
HashMap 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字符串
return gson.toJson(paramsMap);
}
/**
* 生成HttpPost
* @param jsonParams
* @return
*/
private HttpPost generateHttpPost(String jsonParams, String url) {
//拼接请求URL
HttpPost httpPost = new HttpPost(url);
//设置请求头,编码集,内容格式
StringEntity entity = new StringEntity(jsonParams, "utf-8");
entity.setContentType("application/json");
httpPost.setEntity(entity);
httpPost.setHeader("Accept", "application/json");
return httpPost;
}
String url = wxPayConfig.getDomain().concat(WxApiType.NATIVE_PAY.getType());//Mative下单的url
HttpPost httpPost = generateHttpPost(jsonParams, url);
//完成签名并执行请求
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) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("Native下单失败,响应码 = " + statusCode+ ",返回结果 = " + bodyAsString);
throw new IOException("request failed");
}
Gson gson = new Gson();
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();
}
//package.json
"vue-qriously": "^1.1.1",
// main.js
import VueQriously from 'vue-qriously'
Vue.use(VueQriously)
//index.vue
避免重复创建订单,查询已存在的未过期的该用户未支付的同款商品
的订单
@Override
public OrderInfo createOrderByProductId(Long productId) {
OrderInfo orderInfo = getNoPayOrderByProductId(productId);
if (orderInfo != null) {
return orderInfo;
}
orderInfo = new OrderInfo();
orderInfo.setTitle("test")
.setOrderNo(OrderNoUtils.getOrderNo())
.setProductId(productId)
.setTotalFee(1)
.setOrderStatus(OrderStatus.NOTPAY.getType());
save(orderInfo);
return orderInfo;
}
private OrderInfo getNoPayOrderByProductId(Long productId) {
return lambdaQuery().eq(OrderInfo::getProductId, productId)
.eq(OrderInfo::getOrderStatus, OrderStatus.NOTPAY.getType()).one();
}
log.info("生成订单");
OrderInfo orderInfo = orderInfoService.createOrderByProductId(productId);
String codeUrl = orderInfo.getCodeUrl();
if (!StringUtils.isBlank(codeUrl)) {
log.info("订单已存在,二维码已保存");
Map<String, Object> map = new HashMap<>();
map.put("codeUrl", codeUrl);
map.put("orderNo", orderInfo.getOrderNo());
return map;
}
...
//保存二维码
orderInfoService.saveCodeUrl(orderInfo.getOrderNo(), codeUrl);
Map<String, Object> map = new HashMap<>();
HTTP请求方法\n
URL\n
请求时间戳\n
请求随机串\n
请求报文主体\n
绝大多数编程语言提供的签名函数支持对签名数据进行签名。强烈建议商户调用该类函数,使用商户私钥对待签名串进行SHA256 with RSA签名,并对签名结果进行Base64编码
得到签名值
$ echo -n -e \
"GET\n/v3/certificates\n1554208460\n593BEC0C930BF1AFEB40B4A08C8FB242\n\n" \
| openssl dgst -sha256 -sign apiclient_key.pem \
| openssl base64 -A
uOVRnA4qG/MNnYzdQxJanN+zU+lTgIcnU9BxGw5dKjK+VdEUz2FeIoC+D5sB/LN+nGzX3hfZg6r5wT1pl2ZobmIc6p0ldN7J6yDgUzbX8Uk3sD4a4eZVPTBvqNDoUqcYMlZ9uuDdCvNv4TM3c1WzsXUrExwVkI1XO5jCNbgDJ25nkT/c1gIFvqoogl7MdSFGc4W4xZsqCItnqbypR3RuGIlR9h9vlRsy7zJR9PBI83X8alLDIfR1ukt1P7tMnmogZ0cuDY8cZsd8ZlCgLadmvej58SLsIkVxFJ8XyUgx9FmutKSYTmYtWBZ0+tNvfGmbXU7cob8H/4nLBiCwIUFluw==
微信支付商户API v3要求请求通过HTTP Authorization
头来传递签名。 Authorization
由认证类型和签名信息两个部分组成。
下面我们使用命令行演示如何生成签名。
Authorization: 认证类型 签名信息
具体组成为:
1.认证类型,目前为WECHATPAY2-SHA256-RSA2048
2.签名信息
mchid
序列号serial_no
,用于声明所使用的证书nonce_str
timestamp
signature
Authorization
头的示例如下:(注意,示例因为排版可能存在换行,实际数据应在一行)
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900009191",nonce_str="593BEC0C930BF1AFEB40B4A08C8FB242",signature="uOVRnA4qG/MNnYzdQxJanN+zU+lTgIcnU9BxGw5dKjK+VdEUz2FeIoC+D5sB/LN+nGzX3hfZg6r5wT1pl2ZobmIc6p0ldN7J6yDgUzbX8Uk3sD4a4eZVPTBvqNDoUqcYMlZ9uuDdCvNv4TM3c1WzsXUrExwVkI1XO5jCNbgDJ25nkT/c1gIFvqoogl7MdSFGc4W4xZsqCItnqbypR3RuGIlR9h9vlRsy7zJR9PBI83X8alLDIfR1ukt1P7tMnmogZ0cuDY8cZsd8ZlCgLadmvej58SLsIkVxFJ8XyUgx9FmutKSYTmYtWBZ0+tNvfGmbXU7cob8H/4nLBiCwIUFluw==",timestamp="1554208460",serial_no="1DDE55AD98ED71D6EDD4A4A16996DE7B47773A8C"
最终我们可以组一个包含了签名的HTTP请求了。
$ curl https://api.mch.weixin.qq.com/v3/certificates -H 'Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900009191",nonce_str="593BEC0C930BF1AFEB40B4A08C8FB242",signature="uOVRnA4qG/MNnYzdQxJanN+zU+lTgIcnU9BxGw5dKjK+VdEUz2FeIoC+D5sB/LN+nGzX3hfZg6r5wT1pl2ZobmIc6p0ldN7J6yDgUzbX8Uk3sD4a4eZVPTBvqNDoUqcYMlZ9uuDdCvNv4TM3c1WzsXUrExwVkI1XO5jCNbgDJ25nkT/c1gIFvqoogl7MdSFGc4W4xZsqCItnqbypR3RuGIlR9h9vlRsy7zJR9PBI83X8alLDIfR1ukt1P7tMnmogZ0cuDY8cZsd8ZlCgLadmvej58SLsIkVxFJ8XyUgx9FmutKSYTmYtWBZ0+tNvfGmbXU7cob8H/4nLBiCwIUFluw==",timestamp="1554208460",serial_no="1DDE55AD98ED71D6EDD4A4A16996DE7B47773A8C"'
计算签名的示例代码如下。
import okhttp3.HttpUrl;
import java.security.Signature;
import java.util.Base64;
// Authorization:
// GET - getToken("GET", httpurl, "")
// POST - getToken("POST", httpurl, json)
String schema = "WECHATPAY2-SHA256-RSA2048";
HttpUrl httpurl = HttpUrl.parse(url);
String getToken(String method, HttpUrl url, String body) {
String nonceStr = "your nonce string";
long timestamp = System.currentTimeMillis() / 1000;
String message = buildMessage(method, url, timestamp, nonceStr, body);
String signature = sign(message.getBytes("utf-8"));
return "mchid=\"" + yourMerchantId + "\","
+ "nonce_str=\"" + nonceStr + "\","
+ "timestamp=\"" + timestamp + "\","
+ "serial_no=\"" + yourCertificateSerialNo + "\","
+ "signature=\"" + signature + "\"";
}
String sign(byte[] message) {
Signature sign = Signature.getInstance("SHA256withRSA");
sign.initSign(yourPrivateKey);
sign.update(message);
return Base64.getEncoder().encodeToString(sign.sign());
}
String buildMessage(String method, HttpUrl url, long timestamp, String nonceStr, String body) {
String canonicalUrl = url.encodedPath();
if (url.encodedQuery() != null) {
canonicalUrl += "?" + url.encodedQuery();
}
return method + "\n"
+ canonicalUrl + "\n"
+ timestamp + "\n"
+ nonceStr + "\n"
+ body + "\n";
}
如果您的请求返回了签名错误401 Unauthorized,请参考 常见问题之签名相关
如果验证商户的请求签名正确,微信支付会在应答的HTTP头部中包括应答签名。我们建议商户验证应答签名。
同样的,微信支付会在回调的HTTP头部中包括回调报文的签名。商户必须 验证回调的签名,以确保回调是由微信支付发送。
微信支付API v3使用微信支付 的平台私钥(不是商户私钥 )进行应答签名。相应的,商户的技术人员应使用微信支付平台证书中的公钥验签。目前平台证书只提供API进行下载,请参考 获取平台证书列表。
微信支付的平台证书序列号位于HTTP头Wechatpay-Serial
。验证签名前,请商户先检查序列号是否跟商户当前所持有的 微信支付平台证书的序列号一致。如果不一致,请重新获取证书。否则,签名的私钥和证书不匹配,将无法成功验证签名。
首先,商户先从应答中获取以下信息。
Wechatpay-Timestamp
中的应答时间戳。Wechatpay-Nonce
中的应答随机串。然后,请按照以下规则构造应答的验签名串。签名串共有三行,行尾以\n
结束,包括最后一行。\n
为换行符(ASCII编码值为0x0A)。若应答报文主体为空(如HTTP状态码为204 No Content
),最后一行仅为一个\n
换行符。
应答时间戳\n
应答随机串\n
应答报文主体\n
如某个应答的HTTP报文为(省略了ciphertext的具体内容):
HTTP/1.1 200 OK
Server: nginx
Date: Tue, 02 Apr 2019 12:59:40 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 2204
Connection: keep-alive
Keep-Alive: timeout=8
Content-Language: zh-CN
Request-ID: e2762b10-b6b9-5108-a42c-16fe2422fc8a
Wechatpay-Nonce: c5ac7061fccab6bf3e254dcf98995b8c
Wechatpay-Signature: CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA==
Wechatpay-Timestamp: 1554209980
Wechatpay-Serial: 5157F09EFDC096DE15EBE81A47057A7232F1B8E1
Cache-Control: no-cache, must-revalidate
{"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"4de73afd28b6","associated_data":"certificate","ciphertext":"..."}}]}
则验签名串为
1554209980
c5ac7061fccab6bf3e254dcf98995b8c
{"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"4de73afd28b6","associated_data":"certificate","ciphertext":"..."}}]}
微信支付的应答签名通过HTTP头Wechatpay-Signature
传递。(注意,示例因为排版可能存在换行,实际数据应在一行)
Wechatpay-Signature: CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA==
对 Wechatpay-Signature
的字段值使用Base64进行解码,得到应答签名。
某些代理服务器或CDN服务提供商,转发时会“过滤”微信支付扩展的HTTP头,导致应用层无法取到微信支付的签名信息。商户遇到这种情况时,我们建议尝试调整代理服务器配置,或者通过直连的方式访问微信支付的服务器和接收通知。
很多编程语言的签名验证函数支持对验签名串和签名 进行签名验证。强烈建议商户调用该类函数,使用微信支付平台公钥对验签名串和签名进行SHA256 with RSA签名验证。
下面展示使用命令行演示如何进行验签。假设我们已经获取了平台证书并保存为1900009191_wxp_cert.pem
。
首先,从微信支付平台证书导出微信支付平台公钥
$ openssl x509 -in 1900009191_wxp_cert.pem -pubkey -noout > 1900009191_wxp_pub.pem
$ cat 1900009191_wxp_pub.pem
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4zej1cqugGQtVSY2Ah8R
MCKcr2UpZ8Npo+5Ja9xpFPYkWHaF1Gjrn3d5kcwAFuHHcfdc3yxDYx6+9grvJnCA
2zQzWjzVRa3BJ5LTMj6yqvhEmtvjO9D1xbFTA2m3kyjxlaIar/RYHZSslT4VmjIa
tW9KJCDKkwpM6x/RIWL8wwfFwgz2q3Zcrff1y72nB8p8P12ndH7GSLoY6d2Tv0OB
2+We2Kyy2+QzfGXOmLp7UK/pFQjJjzhSf9jxaWJXYKIBxpGlddbRZj9PqvFPTiep
8rvfKGNZF9Q6QaMYTpTp/uKQ3YvpDlyeQlYe4rRFauH3mOE6j56QlYQWivknDX9V
rwIDAQAB
-----END PUBLIC KEY-----
Java支持使用证书初始化签名对象,详见 initVerify(Certificate),并不需要先导出公钥。
然后,把签名base64解码后保存为文件signature.txt
$ openssl base64 -d -A <<< \ 'CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA==' > signature.txt
最后,验证签名 验签名串进行摘要算法
与签名解码后用微信公钥解密
的进行比对
签名生成:摘要->公钥加密->Base64编码
$ openssl dgst -sha256 -verify 1900009191_wxp_pub.pem -signature signature.txt << EOF
1554209980
c5ac7061fccab6bf3e254dcf98995b8c
{"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"d215b0511e9c","associated_data":"certificate","ciphertext":"..."}}]}
EOF
Verified OK
设置authToken
ngrok config add-authtoken 2DEjRSBgc9J5A1oVKs2SU9kZ1h5_4B9NJUkDFACvK218GrESM
启动服务
ngrok http 8090
测试外网访问
你获得的外网地址/api/test
Linux系统命令前要加上./
@PostMapping("/native/notify")
public String nativeNotify(HttpServletRequest request, HttpServletResponse response) {
Gson gson = new Gson();
Map<String, String> map = new HashMap<>();//应答对象
//处理通知参数,将请求体转换为字符串
try {
String body = HttpUtils.readData(request);
log.info("支付通知的完整数据 ===> {}", body);
//验签和解析请求体并解密报文
String decryptData = notificationHandlerUtils.getDecryptData(request, body);
log.info("支付通知解密完的数据 ===> {}", decryptData);
//对订单进行处理
wxPayService.processOrder(decryptData);
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);
}
@Component
public class NotificationHandlerUtils {
@Resource
private Verifier verifier;
@Resource
private WxPayConfig wxPayConfig;
/**
* 验签request并解析加密数据获取订单的详情
* @param request
* @param body
* @return
*/
public String getDecryptData(HttpServletRequest request, String body) {
String serial = request.getHeader(WECHAT_PAY_SERIAL);
String nonce = request.getHeader(WECHAT_PAY_NONCE);
String timestamp = request.getHeader(WECHAT_PAY_TIMESTAMP);
String signature = request.getHeader(WECHAT_PAY_SIGNATURE);
// 构建request,传入必要参数
NotificationRequest req = new NotificationRequest.Builder()
.withSerialNumber(serial)
.withNonce(nonce)
.withTimestamp(timestamp)
.withSignature(signature)
.withBody(body)
.build();
NotificationHandler handler = new NotificationHandler(verifier, wxPayConfig.getApiV3Key().getBytes(StandardCharsets.UTF_8));
// 验签和解析请求体
Notification notification = null;
try {
notification = handler.parse(req);
} catch (ValidationException e) {
throw new RuntimeException("验签失败");
} catch (ParseException e) {
throw new RuntimeException("解析失败");
}
// 从notification中获取解密报文
return notification.getDecryptData();
}
}
/**
* 处理订单
*
* @param decryptData 解密后的报文
*/
@Override
public void processOrder(String decryptData) {
Gson gson = new Gson();
Map<String, Object> decryptDataMap = gson.fromJson(decryptData, HashMap.class);
String orderNo = (String) decryptDataMap.get("out_trade_no");
/*在对业务数据进行状态检查和处理之前,
要采用数据锁进行并发控制,
以避免函数重入造成的数据混乱*/
//尝试获取锁:
// 成功获取则立即返回true,获取失败则立即返回false。不必一直等待锁的释放
if (lock.tryLock()) {
try {
//处理重复通知
//保证接口调用的幂等性:无论接口被调用多少次,产生的结果是一致的
String orderStatus = orderInfoService.getOrderStatus(orderNo);
if (!OrderStatus.NOTPAY.getType().equals(orderStatus)) {
return;
}
//更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);
//记录支付日志
paymentInfoService.createPaymentInfo(decryptData);
} finally {
lock.unlock();
}
}
}
@Override
public void updateStatusByOrderNo(String orderNo, OrderStatus orderStatus) {
log.info("更新订单状态 ===> {}", orderStatus.getType());
LambdaQueryWrapper<OrderInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(OrderInfo::getOrderNo, orderNo);
OrderInfo orderInfo = new OrderInfo().setOrderStatus(orderStatus.getType());
update(orderInfo, wrapper);
}
@Override
public void createPaymentInfo(String decryptData) {
log.info("记录支付日志");
Gson gson = new Gson();
Map<String, Object> decryptDataMap = gson.fromJson(decryptData, HashMap.class);
String orderNo = (String)decryptDataMap.get("out_trade_no");
String transactionId = (String)decryptDataMap.get("transaction_id");
String tradeType = (String)decryptDataMap.get("trade_type");
String tradeState = (String)decryptDataMap.get("trade_state");
Map<String, Object> amount = (Map)decryptDataMap.get("amount");
Integer payerTotal = ((Double) amount.get("payer_total")).intValue();
PaymentInfo paymentInfo = new PaymentInfo();
paymentInfo.setOrderNo(orderNo)
.setPaymentType(PayType.WXPAY.getType())
.setTransactionId(transactionId)
.setTradeType(tradeType)
.setTradeState(tradeState)
.setPayerTotal(payerTotal)
.setContent(decryptData);
save(paymentInfo);
}
适用对象: 直连商户
请求URL: https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}/close
请求方式: POST
path指该参数为路径参数
query指该参数需在请求URL传参
body指该参数需在请求JSON传参
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
直连商户号 | mchid | string[1,32] | 是 | body 直连商户的商户号,由微信支付生成并下发。 示例值:1230000109 |
商户订单号 | out_trade_no | string[6,32] | 是 | path 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一 示例值:1217752501201407033233368018 |
请求示例
{
"mchid": "1230000109"
}
@ApiOperation("用户取消订单")
@PostMapping("/cancel/{orderNo}")
public R cancel(@PathVariable String orderNo) throws IOException {
log.info("取消订单");
wxPayService.cancelOrder(orderNo);
return R.ok().setMessage("订单已取消");
}
@Override
public void cancelOrder(String orderNo) throws IOException {
//调用微信支付的关单接口
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);//取消订单url
//组装json请求体
Gson gson = new Gson();
Map<String, String> paramsMap = new HashMap();
paramsMap.put("mchid", wxPayConfig.getMchId());
//将参数转换成json字符串
String jsonParams = gson.toJson(paramsMap);
HttpPost httpPost = generateHttpPost(jsonParams, url);
//完成签名并执行请求
CloseableHttpResponse response = wxPayClient.execute(httpPost);
int statusCode = response.getStatusLine().getStatusCode();
try {
if (statusCode == 200) { //处理成功
log.info("成功200");
} else if (statusCode == 204) { //处理成功,无返回Body
log.info("成功204");
} else {
log.info("取消订单失败,响应码 = " + statusCode);
throw new IOException("request failed");
}
} finally {
response.close();
}
}
适用对象: 直连商户
请求URL: https://api.mch.weixin.qq.com/v3/pay/transactions/id/{transaction_id}
**请求方式:**GET
path指该参数为路径参数
query指该参数需在请求URL传参
body指该参数需在请求JSON传参
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
直连商户号 | mchid | string[1,32] | 是 | query 直连商户的商户号,由微信支付生成并下发。 示例值:1230000109 |
微信支付订单号 | transaction_id | string[1,32] | 是 | path 微信支付系统生成的订单号 示例值:1217752501201407033233368018 |
查询订单
适用对象: 直连商户
请求URL: https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}
**请求方式:**GET
path指该参数为路径参数
query指该参数需在请求URL传参
body指该参数需在请求JSON传参
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
直连商户号 | mchid | string[1,32] | 是 | query 直连商户的商户号,由微信支付生成并下发。 示例值:1230000109 |
商户订单号 | out_trade_no | string[6,32] | 是 | path 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一。 特殊规则:最小字符长度为6 示例值:1217752501201407033233368018 |
https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/1217752501201407033233368018?mchid=1230000109
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
应用ID | appid | string[1,32] | 是 | 直连商户申请的公众号或移动应用appid。 示例值:wxd678efh567hg6787 |
直连商户号 | mchid | string[1,32] | 是 | 直连商户的商户号,由微信支付生成并下发。 示例值:1230000109 |
商户订单号 | out_trade_no | string[6,32] | 是 | 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一,详见【商户订单号】。 示例值:1217752501201407033233368018 |
微信支付订单号 | transaction_id | string[1,32] | 否 | 微信支付系统生成的订单号。 示例值:1217752501201407033233368018 |
交易类型 | trade_type | string[1,16] | 否 | 交易类型,枚举值: JSAPI:公众号支付 NATIVE:扫码支付 APP:APP支付 MICROPAY:付款码支付 MWEB:H5支付 FACEPAY:刷脸支付 示例值:MICROPAY |
交易状态 | trade_state | string[1,32] | 是 | 交易状态,枚举值: SUCCESS:支付成功 REFUND:转入退款 NOTPAY:未支付 CLOSED:已关闭 REVOKED:已撤销(仅付款码支付会返回) USERPAYING:用户支付中(仅付款码支付会返回) PAYERROR:支付失败(仅付款码支付会返回) 示例值:SUCCESS |
交易状态描述 | trade_state_desc | string[1,256] | 是 | 交易状态描述 示例值:支付成功 |
付款银行 | bank_type | string[1,32] | 否 | 银行类型,采用字符串类型的银行标识。银行标识请参考《银行类型对照表》 示例值:CMC |
附加数据 | attach | string[1,128] | 否 | 附加数据,在查询API和支付通知中原样返回,可作为自定义参数使用,实际情况下只有支付完成状态才会返回该字段。 示例值:自定义数据 |
支付完成时间 | success_time | string[1,64] | 否 | 支付完成时间,遵循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年5月20日 13点29分35秒。 示例值:2018-06-08T10:34:56+08:00 |
+支付者 | payer | object | 是 | 支付者信息 |
+订单金额 | amount | object | 否 | 订单金额信息,当支付成功时返回该字段。 |
+场景信息 | scene_info | object | 否 | 支付场景描述 |
+优惠功能 | promotion_detail | array | 否 | 优惠功能,享受优惠时返回该字段。 |
{
"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"
}
/**
* 根据订单号查询微信支付查单接口
* 响应体的明文
* @param orderNo
* @return
* @throws IOException
*/
@Override
public String queryOrder(String orderNo) throws IOException {
log.info("查单接口调用 ===> {}", orderNo);
//组装请求URL
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) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("查询订单失败,响应码 = " + statusCode + ",返回结果 = " + bodyAsString);
throw new IOException("request failed");
}
return bodyAsString;
} finally {
response.close();
}
}
@Component
@Slf4j
public class WxPayTask {
@Resource
private OrderInfoService orderInfoService;
@Resource
private WxPayService wxPayService;
/**
* 从第0秒开始每隔30秒执行1次,查询创建超过5分钟,并且未支付的订单
*/
@Scheduled(cron = "0/30 * * * * ?")
public void orderConfirm() throws IOException {
log.info("确认超时订单的状态...");
//获取超时未支付的订单
List<OrderInfo> orderInfoList = orderInfoService.getNoPayOrderByDurations(5);
for (OrderInfo orderInfo : orderInfoList) {
String orderNo = orderInfo.getOrderNo();
log.warn("超时订单 ===> {}", orderNo);
//核实订单状态:调用微信支付主动查询订单
wxPayService.checkOrderStatus(orderNo);
}
}
}
/**
* 从第0秒开始每隔30秒执行1次,查询创建超过5分钟,并且未支付的订单
*
* @param minutes
* @return
*/
@Override
public List<OrderInfo> getNoPayOrderByDurations(int minutes) {
//minutes分钟之前的时间
Instant instant = Instant.now().minus(Duration.ofMinutes(minutes));
List<OrderInfo> orderInfoList = lambdaQuery().eq(OrderInfo::getOrderStatus, OrderStatus.NOTPAY.getType())
.le(OrderInfo::getCreateTime, instant)
.list();
return orderInfoList;
}
/**
* 根据订单号查询微信支付查单接口,核实订单状态
* 如果订单已支付,则更新商户端订单状态,并记录支付日志
* 如果订单未支付,则调用关单接口关闭订单,并更新商户端订单状态
*
* @param orderNo 创建5分钟且未支付的订单号
*/
@Override
public void checkOrderStatus(String orderNo) throws IOException {
log.warn("根据订单号核实订单状态 ===> {}", orderNo);
Gson gson = new Gson();
//查单
String bodyAsString = queryOrder(orderNo);
Map<String, Object> bodyMap = gson.fromJson(bodyAsString, HashMap.class);
String trade_state = (String) bodyMap.get("trade_state");
if (WxTradeState.SUCCESS.getType().equals(trade_state)) {
log.warn("核实订单已支付 ===> {}", orderNo);
//如果确认订单已支付则更新本地订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.SUCCESS);
//记录支付日志
paymentInfoService.createPaymentInfo(bodyAsString);
} else if (WxTradeState.NOTPAY.getType().equals(trade_state)) {
log.warn("核实订单未支付 ===> {}", orderNo);
//关单接口
closeOrder(orderNo);
//更新为超时关闭
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.CLOSED);
}
}
退款状态转变如下:
**适用对象:**直连商户
**请求URL:**https://api.mch.weixin.qq.com/v3/refund/domestic/refunds
**请求方式:**POST
**接口频率:**150qps
path 指该参数为路径参数
query 指该参数为URL参数
body 指该参数需在请求JSON传参
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
微信支付订单号 | transaction_id | string[1, 32] | 二选一 | body原支付交易对应的微信订单号 示例值:1217752501201407033233368018 |
商户订单号 | out_trade_no | string[6, 32] | body原支付交易对应的商户订单号 示例值:1217752501201407033233368018 | |
商户退款单号 | out_refund_no | string[1, 64] | 是 | body商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。 示例值:1217752501201407033233368018 |
退款原因 | reason | string[1, 80] | 否 | body若商户传入,会在下发给用户的退款消息中体现退款原因 示例值:商品已售完 |
退款结果回调url | notify_url | string[8, 256] | 否 | body异步接收微信支付退款结果通知的回调地址,通知url必须为外网可访问的url,不能携带参数。 如果参数中传了notify_url,则商户平台上配置的回调地址将不会生效,优先回调当前传的这个地址。 示例值:https://weixin.qq.com |
退款资金来源 | funds_account | string[1,32] | 否 | body若传递此参数则使用对应的资金账户退款,否则默认使用未结算资金退款(仅对老资金流商户适用) 枚举值: AVAILABLE:可用余额账户 示例值:AVAILABLE |
+金额信息 | amount | object | 是 | body订单金额信息 |
+退款商品 | goods_detail | array | 否 | body指定商品退款需要传此参数,其他场景无需传递 |
{
"transaction_id": "1217752501201407033233368018",
"out_refund_no": "1217752501201407033233368018",
"reason": "商品已售完",
"notify_url": "https://weixin.qq.com",
"funds_account": "AVAILABLE",
"amount": {
"refund": 888,
"from": [
{
"account": "AVAILABLE",
"amount": 444
}
],
"total": 888,
"currency": "CNY"
},
"goods_detail": [
{
"merchant_goods_id": "1217752501201407033233368018",
"wechatpay_goods_id": "1001",
"goods_name": "iPhone6s 16G",
"unit_price": 528800,
"refund_amount": 528800,
"refund_quantity": 1
}
]
}
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
微信支付退款单号 | refund_id | string[1, 32] | 是 | 微信支付退款单号 示例值:50000000382019052709732678859 |
商户退款单号 | out_refund_no | string[1, 64] | 是 | 商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。 示例值:1217752501201407033233368018 |
微信支付订单号 | transaction_id | string[1, 32] | 是 | 微信支付交易订单号 示例值:1217752501201407033233368018 |
商户订单号 | out_trade_no | string[1, 32] | 是 | 原支付交易对应的商户订单号 示例值:1217752501201407033233368018 |
退款渠道 | channel | string[1, 16] | 是 | 枚举值: ORIGINAL:原路退款 BALANCE:退回到余额 OTHER_BALANCE:原账户异常退到其他余额账户 OTHER_BANKCARD:原银行卡异常退到其他银行卡 示例值:ORIGINAL |
退款入账账户 | user_received_account | string[1, 64] | 是 | 取当前退款单的退款入账方,有以下几种情况: 1)退回银行卡:{银行名称}{卡类型}{卡尾号} 2)退回支付用户零钱:支付用户零钱 3)退还商户:商户基本账户商户结算银行账户 4)退回支付用户零钱通:支付用户零钱通 示例值:招商银行信用卡0403 |
退款成功时间 | success_time | string[1, 64] | 否 | 退款成功时间,当退款状态为退款成功时有返回。 示例值:2020-12-01T16:18:12+08:00 |
退款创建时间 | create_time | string[1, 64] | 是 | 退款受理时间 示例值:2020-12-01T16:18:12+08:00 |
退款状态 | status | string[1, 32] | 是 | 退款到银行发现用户的卡作废或者冻结了,导致原路退款银行卡失败,可前往商户平台-交易中心,手动处理此笔退款。 枚举值: SUCCESS:退款成功 CLOSED:退款关闭 PROCESSING:退款处理中 ABNORMAL:退款异常 示例值:SUCCESS |
资金账户 | funds_account | string[1, 32] | 否 | 退款所使用资金对应的资金账户类型 枚举值: UNSETTLED : 未结算资金 AVAILABLE : 可用余额 UNAVAILABLE : 不可用余额 OPERATION : 运营户 BASIC : 基本账户(含可用余额和不可用余额) 示例值:UNSETTLED |
+金额信息 | amount | object | 是 | 金额详细信息 |
+优惠退款信息 | promotion_detail | array | 否 | 优惠退款信息 |
{
"refund_id": "50000000382019052709732678859",
"out_refund_no": "1217752501201407033233368018",
"transaction_id": "1217752501201407033233368018",
"out_trade_no": "1217752501201407033233368018",
"channel": "ORIGINAL",
"user_received_account": "招商银行信用卡0403",
"success_time": "2020-12-01T16:18:12+08:00",
"create_time": "2020-12-01T16:18:12+08:00",
"status": "SUCCESS",
"funds_account": "UNSETTLED",
"amount": {
"total": 100,
"refund": 100,
"from": [
{
"account": "AVAILABLE",
"amount": 444
}
],
"payer_total": 90,
"payer_refund": 90,
"settlement_refund": 100,
"settlement_total": 100,
"discount_refund": 10,
"currency": "CNY"
},
"promotion_detail": [
{
"promotion_id": "109519",
"scope": "SINGLE",
"type": "DISCOUNT",
"amount": 5,
"refund_amount": 100,
"goods_detail": [
{
"merchant_goods_id": "1217752501201407033233368018",
"wechatpay_goods_id": "1001",
"goods_name": "iPhone6s 16G",
"unit_price": 528800,
"refund_amount": 528800,
"refund_quantity": 1
}
]
}
]
}
/**
* 创建退款订单
* @param orderNo
* @param reason
* @return
*/
@Override
public RefundInfo createRefundByOrderNo(String orderNo, String reason) {
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
RefundInfo refundInfo = new RefundInfo();
refundInfo.setOrderNo(orderNo)
.setRefundNo(OrderNoUtils.getRefundNo())
.setTotalFee(orderInfo.getTotalFee())//原金额
.setRefund(orderInfo.getTotalFee())//退款金额
.setReason(reason);
save(refundInfo);
return refundInfo;
}
@Override
public OrderInfo getOrderByOrderNo(String orderNo) {
return lambdaQuery().eq(OrderInfo::getOrderNo, orderNo).one();
}
private String generateJsonParamsOfRefund(RefundInfo refundInfo) {
Gson gson = new Gson();
Map<String, Object> paramsMap = new HashMap<>();
paramsMap.put("out_trade_no", refundInfo.getOrderNo());//商品订单号
paramsMap.put("out_refund_no", refundInfo.getRefundNo());//退款单号
paramsMap.put("reason", refundInfo.getReason());
paramsMap.put("notify_url", wxPayConfig.getNotifyDomain().concat(WxNotifyType.REFUND_NOTIFY.getType()));
Map<String, Object> amountMap = new HashMap<>();
amountMap.put("refund", refundInfo.getRefund());//退款金额
amountMap.put("total", refundInfo.getTotalFee());//原订单金额
amountMap.put("currency", "CNY");
paramsMap.put("amount", amountMap);
//将参数转换成json字符串
return gson.toJson(paramsMap);
}
/**
* 申请退款
*
* @param orderNo
* @param reason
*/
@Override
public void refund(String orderNo, String reason) throws IOException {
log.info("创建退款单记录");
RefundInfo refundInfo = refundInfoService.createRefundByOrderNo(orderNo, reason);
log.info("调用退款API");
String url = wxPayConfig.getDomain().concat(WxApiType.DOMESTIC_REFUNDS.getType());
//json请求体
String jsonParams = generateJsonParamsOfRefund(refundInfo);
HttpPost httpPost = generateHttpPost(jsonParams, url);
//完成签名并执行请求
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) { //处理成功,无返回Body
log.info("成功");
} else {
throw new RuntimeException("退款失败,响应码 = " + statusCode + ",退款返回结果 = " + bodyAsString);
}
//更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_PROCESSING);
//更新退款单
refundInfoService.updateRefund(bodyAsString);
} finally {
response.close();
}
}
/**
* 记录退款记录
* @param content
*/
@Override
public void updateRefund(String content) {
//将json字符串转换成Map
Gson gson = new Gson();
Map<String, String> resultMap = gson.fromJson(content, HashMap.class);
//根据退款单编号修改退款单
LambdaQueryWrapper<RefundInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(RefundInfo::getRefundNo, resultMap.get("out_refund_no"));
RefundInfo refundInfo = new RefundInfo();
refundInfo.setRefundId(resultMap.get("refund_id"));
//1、查询退款和申请退款中的返回参数
if (resultMap.get("status") != null) {
refundInfo.setRefundStatus(resultMap.get("status"));
refundInfo.setContentReturn(content);
}
//2、退款回调中的回调参数
if (resultMap.get("refund_status") != null) {
refundInfo.setRefundStatus(resultMap.get("refund_status"));
refundInfo.setContentNotify(content);
}
update(refundInfo,wrapper);
}
**适用对象:**直连商户
**请求URL:**https://api.mch.weixin.qq.com/v3/refund/domestic/refunds/{out_refund_no}
**请求方式:**GET
path 指该参数为路径参数
query 指该参数为URL参数
body 指该参数需在请求JSON传参
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
商户退款单号 | out_refund_no | string[1, 64] | 是 | path商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。 示例值:1217752501201407033233368018 |
https://api.mch.weixin.qq.com/v3/refund/domestic/refunds/1217752501201407033233368018
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
微信支付退款单号 | refund_id | string[1, 32] | 是 | 微信支付退款单号 示例值:50000000382019052709732678859 |
商户退款单号 | out_refund_no | string[1, 64] | 是 | 商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。 示例值:1217752501201407033233368018 |
微信支付订单号 | transaction_id | string[1, 32] | 是 | 微信支付交易订单号 示例值:1217752501201407033233368018 |
商户订单号 | out_trade_no | string[1, 32] | 是 | 原支付交易对应的商户订单号 示例值:1217752501201407033233368018 |
退款渠道 | channel | string[1, 16] | 是 | 枚举值: ORIGINAL:原路退款 BALANCE:退回到余额 OTHER_BALANCE:原账户异常退到其他余额账户 OTHER_BANKCARD:原银行卡异常退到其他银行卡 示例值:ORIGINAL |
退款入账账户 | user_received_account | string[1, 64] | 是 | 取当前退款单的退款入账方,有以下几种情况: 1)退回银行卡:{银行名称}{卡类型}{卡尾号} 2)退回支付用户零钱:支付用户零钱 3)退还商户:商户基本账户商户结算银行账户 4)退回支付用户零钱通:支付用户零钱通 示例值:招商银行信用卡0403 |
退款成功时间 | success_time | string[1, 64] | 否 | 退款成功时间,当退款状态为退款成功时有返回。 示例值:2020-12-01T16:18:12+08:00 |
退款创建时间 | create_time | string[1, 64] | 是 | 退款受理时间 示例值:2020-12-01T16:18:12+08:00 |
退款状态 | status | string[1, 32] | 是 | 款到银行发现用户的卡作废或者冻结了,导致原路退款银行卡失败,可前往商户平台-交易中心,手动处理此笔退款。 枚举值: SUCCESS:退款成功 CLOSED:退款关闭 PROCESSING:退款处理中 ABNORMAL:退款异常 示例值:SUCCESS |
资金账户 | funds_account | string[1, 32] | 否 | 退款所使用资金对应的资金账户类型 枚举值: UNSETTLED : 未结算资金 AVAILABLE : 可用余额 UNAVAILABLE : 不可用余额 OPERATION : 运营户 BASIC : 基本账户(含可用余额和不可用余额) 示例值:UNSETTLED |
+金额信息 | amount | object | 是 | 金额详细信息 |
+优惠退款信息 | promotion_detail | array | 否 | 优惠退款信息 |
{
"refund_id": "50000000382019052709732678859",
"out_refund_no": "1217752501201407033233368018",
"transaction_id": "1217752501201407033233368018",
"out_trade_no": "1217752501201407033233368018",
"channel": "ORIGINAL",
"user_received_account": "招商银行信用卡0403",
"success_time": "2020-12-01T16:18:12+08:00",
"create_time": "2020-12-01T16:18:12+08:00",
"status": "SUCCESS",
"funds_account": "UNSETTLED",
"amount": {
"total": 100,
"refund": 100,
"from": [
{
"account": "AVAILABLE",
"amount": 444
}
],
"payer_total": 90,
"payer_refund": 90,
"settlement_refund": 100,
"settlement_total": 100,
"discount_refund": 10,
"currency": "CNY"
},
"promotion_detail": [
{
"promotion_id": "109519",
"scope": "SINGLE",
"type": "DISCOUNT",
"amount": 5,
"refund_amount": 100,
"goods_detail": [
{
"merchant_goods_id": "1217752501201407033233368018",
"wechatpay_goods_id": "1001",
"goods_name": "iPhone6s 16G",
"unit_price": 528800,
"refund_amount": 528800,
"refund_quantity": 1
}
]
}
]
}
/**
* 从第0秒开始每隔30秒执行1次,查询创建超过5分钟,并且未成功的退款单
*/
@Scheduled(cron = "0/30 * * * * ?")
public void refundConfirm() throws IOException {
log.info("确认超时退款订单的状态...");
//获取超时退款仍在处理中的订单
List<RefundInfo> RefundInfoList = refundInfoService.getNoPayOrderByDurations(5);
for (RefundInfo refundInfo : RefundInfoList) {
String refundNo = refundInfo.getRefundNo();
log.warn("超时未退款的退款单号 ===> {}", refundNo);
//核实订单状态:调用微信支付查询退款接口
wxPayService.checkRefundStatus(refundNo);
}
}
/**
* 找出申请退款超过minutes分钟并且未成功的退款单
* @param minutes
* @return
*/
@Override
public List<RefundInfo> getNoPayOrderByDurations(int minutes) {
//minutes分钟之前的时间
Instant instant = Instant.now().minus(Duration.ofMinutes(minutes));
List<RefundInfo> RefundInfoList = lambdaQuery().eq(RefundInfo::getRefundStatus, WxRefundStatus.PROCESSING.getType())
.le(RefundInfo::getCreateTime, instant)
.list();
return RefundInfoList;
}
/**
* 1.调用查询退款记录接口
* 2.更新数据库
* @param refundNo
* @throws IOException
*/
@Override
public void checkRefundStatus(String refundNo) throws IOException {
log.warn("根据退款订单号核实退款订单状态 ===> {}", refundNo);
Gson gson = new Gson();
//查询退款订单
String bodyAsString = queryRefund(refundNo);
Map<String, String> bodyMap = gson.fromJson(bodyAsString, HashMap.class);
String status = bodyMap.get("status");
String orderNo = bodyMap.get("out_trade_no");
if (WxRefundStatus.SUCCESS.getType().equals(status)) {
log.warn("核实订单退款成功 ===> {}", refundNo);
//如果确认订单已退款成功则更新本地订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_SUCCESS);
} else if (WxRefundStatus.ABNORMAL.getType().equals(status)) {
log.warn("核实订单退款异常 ===> {}", refundNo);
//如果确认订单退款异常则更新本地订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_ABNORMAL);
}
//保存响应体到数据库中
refundInfoService.updateRefund(bodyAsString);
}
/**
* 调用查询退款单接口,核实订单状态
* @param refundNo
* @return
* @throws IOException
*/
@Override
public String queryRefund(String refundNo) throws IOException {
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) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("查询退款失败,响应码 = " + statusCode + ",查询退款返回结果 = " + bodyAsString);
throw new IOException("request failed");
}
return bodyAsString;
} finally {
response.close();
}
}
**适用对象:**直连商户
**请求方式:**POST
**请求URL:**该链接是通过[申请退款接口]指定的notify_url,必须为https协议。如果链接无法访问,商户将无法接收到微信通知。 通知url必须为直接可访问的url,不能携带参数。示例:“https://pay.weixin.qq.com/wxpay/pay.action”
商户退款完成后,微信会把相关退款结果和用户信息发送给清算机构,清算机构需要接收处理后返回应答成功,然后继续给异步通知到下游从业机构。
对后台通知交互时,如果微信收到应答不是成功或超时,微信认为通知失败,微信会通过一定的策略定期重新发起通知,尽可能提高通知的成功率,但微信不保证通知最终能成功。(通知频率为15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h - 总计 24h4m)
退款结果通知是以POST 方法访问商户设置的通知url,通知的数据以JSON 格式通过请求主体(BODY)传输。通知的数据包括了加密的支付结果详情
(注:由于涉及到回调加密和解密,商户必须先设置好apiv3密钥后才能解密回调通知,apiv3密钥设置文档指引详见APIv3密钥设置指引)
下面详细描述对通知数据进行解密的流程:
注: AEAD_AES_256_GCM算法的接口细节,请参考rfc5116。微信支付使用的密钥key长度为32个字节,随机串nonce长度12个字节,associated_data长度小于16个字节并可能为空字符串。
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
通知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年5月20日13点29分35秒。 示例值:2018-06-08T10:34:56+08:00 |
通知类型 | event_type | string[1,32] | 是 | 通知的类型: REFUND.SUCCESS:退款成功通知 REFUND.ABNORMAL:退款异常通知 REFUND.CLOSED:退款关闭通知 示例值:REFUND.SUCCESS |
通知简要说明 | summary | string[1,16] | 是 | 通知简要说明 示例值:退款成功 |
通知数据类型 | resource_type | string[1,32] | 是 | 通知的资源数据类型,支付成功通知为encrypt-resource 示例值:encrypt-resource |
+通知数据 | resource | object | 是 | 通知资源数据 json格式,见示例 |
加密不能保证通知请求来自微信。微信会对发送给商户的通知进行签名,并将签名值放在通知的HTTP头Wechatpay-Signature。商户应当验证签名,以确认请求来自微信,而不是其他的第三方。签名验证的算法请参考 《微信支付API v3签名验证》。
{
"id":"EV-2018022511223320873",
"create_time":"2018-06-08T10:34:56+08:00",
"resource_type":"encrypt-resource",
"event_type":"REFUND.SUCCESS",
"summary":"退款成功",
"resource" : {
"original_type": "refund",
"algorithm":"AEAD_AES_256_GCM",
"ciphertext": "...",
"associated_data": "",
"nonce": "..."
}
}
{
"mchid": "1900000100",
"transaction_id": "1008450740201411110005820873",
"out_trade_no": "20150806125346",
"refund_id": "50200207182018070300011301001",
"out_refund_no": "7752501201407033233368018",
"refund_status": "SUCCESS",
"success_time": "2018-06-08T10:34:56+08:00",
"user_received_account": "招商银行信用卡0403",
"amount" : {
"total": 999,
"refund": 999,
"payer_total": 999,
"payer_refund": 999
}
}
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
直连商户号 | mchid | string[1,32] | 是 | 直连商户的商户号,由微信支付生成并下发。 示例值:1900000100 |
商户订单号 | out_trade_no | string[1,32] | 是 | 返回的商户订单号 示例值: 1217752501201407033233368018 |
微信支付订单号 | transaction_id | string[1,32] | 是 | 微信支付订单号 示例值: 1217752501201407033233368018 |
商户退款单号 | out_refund_no | string[1,64] | 是 | 商户退款单号 示例值: 1217752501201407033233368018 |
微信支付退款单号 | refund_id | string[1,32] | 是 | 微信退款单号 示例值: 1217752501201407033233368018 |
退款状态 | refund_status | string[1,16] | 是 | 退款状态,枚举值: SUCCESS:退款成功 CLOSED:退款关闭 ABNORMAL:退款异常,退款到银行发现用户的卡作废或者冻结了,导致原路退款银行卡失败,可前往【商户平台—>交易中心】,手动处理此笔退款 示例值:SUCCESS |
退款成功时间 | success_time | string[1,64] | 否 | 1、退款成功时间,遵循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年5月20日13点29分35秒。 2、当退款状态为退款成功时返回此参数。 示例值:2018-06-08T10:34:56+08:00 |
退款入账账户 | user_received_account | string[1,64] | 是 | 取当前退款单的退款入账方。 1、退回银行卡:{银行名称}{卡类型}{卡尾号} 2、退回支付用户零钱: 支付用户零钱 3、退还商户: 商户基本账户、商户结算银行账户 4、退回支付用户零钱通:支付用户零钱通 示例值:招商银行信用卡0403 |
+金额信息 | amount | object | 是 | 金额信息 |
**接收成功:**HTTP应答状态码需返回200或204,无需返回应答报文。
**接收失败:**HTTP应答状态码需返回5XX或4XX,同时需返回应答报文,格式如下:
**注意:**重试过多会导致微信支付端积压过多通知而堵塞,影响其他正常通知。
参数名 | 变量 | 类型[长度限制] | 必填 | 描述 |
---|---|---|---|---|
返回状态码 | code | string[1,32] | 是 | 错误码,SUCCESS为接收成功,其他错误码为失败 示例值:SUCCESS |
返回信息 | message | string[1,256] | 否 | 返回信息,如非空,为错误原因 示例值:系统错误 |
{
"code": "SUCCESS"
}
/**
* 退款结果通知
* 退款状态改变后,微信会把相关退款结果发送给商户
*/
@PostMapping("/refunds/notify")
public String refundsNotify(HttpServletRequest request, HttpServletResponse response) throws Exception {
log.info("退款通知执行");
Gson gson = new Gson();
Map<String, String> map = new HashMap<>();//应答对象
//处理通知参数,将请求体转换为字符串
try {
String body = HttpUtils.readData(request);
log.info("退款通知的完整数据 ===> {}", body);
//验签和解析请求体并解密报文
String decryptData = notificationHandlerUtils.getDecryptData(request, body);
log.info("退款通知解密完的数据 ===> {}", decryptData);
//对订单进行处理
wxPayService.processRefund(decryptData);
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);
}
}
/**
* 处理退款通知
* @param decryptData 解密后的明文
*/
@Override
public void processRefund(String decryptData) {
Gson gson = new Gson();
Map<String, Object> decryptDataMap = gson.fromJson(decryptData, HashMap.class);
String orderNo = (String) decryptDataMap.get("out_trade_no");
/*在对业务数据进行状态检查和处理之前,
要采用数据锁进行并发控制,
以避免函数重入造成的数据混乱*/
//尝试获取锁:
// 成功获取则立即返回true,获取失败则立即返回false。不必一直等待锁的释放
if (lock.tryLock()) {
try {
//处理重复通知
//保证接口调用的幂等性:无论接口被调用多少次,产生的结果是一致的
String orderStatus = orderInfoService.getOrderStatus(orderNo);
//申请退款后的OrderStatus == REFUND_PROCESSING
if (!OrderStatus.REFUND_PROCESSING.getType().equals(orderStatus)) {
return;
}
//更新订单状态 -- 退款成功
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatus.REFUND_SUCCESS);
//将回调通知的请求体存入数据库
refundInfoService.updateRefund(decryptData);
} finally {
lock.unlock();
}
}
}
/**
* 申请账单
*
* @param billDate
* @param type
* @return
*/
@Override
public String queryBill(String billDate, String type) throws IOException {
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) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("申请账单异常,响应码 = " + statusCode + ",申请账单异常返回结果 = " + bodyAsString);
throw new IOException("request failed");
}
//获取账单下载地址
Gson gson = new Gson();
Map<String, String> resultMap = gson.fromJson(bodyAsString, HashMap.class);
return resultMap.get("download_url");
} finally {
response.close();
}
/**
* 获取 HttpClient 对象 无需进行应答签名验证,跳过验签的流程
*
* @return
*/
@Bean(name = "wxPayNoSignClient")
public CloseableHttpClient getWxPayNoSignClient() {
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
//设置商户信息
.withMerchant(mchId, mchSerialNo, getPrivateKey(privateKeyPath))
//无需进行签名验证、通过withValidator((response) -> true)实现
.withValidator((response) -> true);
// 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签,并进行证书自动更新
CloseableHttpClient httpClient = builder.build();
return httpClient;
}
/**
* 下载账单
* @param billDate
* @param type
* @return
*/
@Override
public String downloadBill(String billDate, String type) throws IOException {
log.warn("下载账单接口调用 {}, {}", billDate, type);
String downloadUrl = 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) { //处理成功,无返回Body
log.info("成功");
} else {
log.info("下载账单异常,响应码 = " + statusCode + ",下载账单异常返回结果 = " + bodyAsString);
throw new IOException("request failed");
}
return bodyAsString;
} finally {
response.close();
}
}