feat(pigx-pay-platform): 支持微信支付 APIV3 回调与下单,

This commit is contained in:
PIGCLOUD committed 2026-05-11 11:19:16 +08:00
1 parent a1edbfcc6e
commit 26efa7acbf
11 files changed
+300 -59

No files matched your search

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2018-2026, lengleng All rights reserved.
* Copyright (c) 2018-2025, lengleng All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
@@ -19,6 +19,7 @@ package com.pig4cloud.pigx.pay.controller;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -32,9 +33,14 @@ import com.pig4cloud.pigx.common.log.annotation.SysLog;
import com.pig4cloud.pigx.common.security.annotation.HasPermission;
import com.pig4cloud.pigx.common.security.annotation.Inner;
import com.pig4cloud.pigx.common.xss.core.XssCleanIgnore;
import com.pig4cloud.pigx.pay.entity.PayChannel;
import com.pig4cloud.pigx.pay.entity.PayNotifyRecord;
import com.pig4cloud.pigx.pay.handler.PayNotifyCallbakHandler;
import com.pig4cloud.pigx.pay.service.PayChannelService;
import com.pig4cloud.pigx.pay.service.PayNotifyRecordService;
import com.pig4cloud.pigx.pay.utils.PayChannelNameEnum;
import com.pig4cloud.pigx.pay.utils.WeChatPayV3Config;
import com.pig4cloud.pigx.pay.utils.WeChatPayV3NotifyParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -44,7 +50,9 @@ import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.web.server.header.ContentTypeOptionsServerHttpHeadersWriter;
import org.springframework.web.bind.annotation.*;
@@ -67,6 +75,8 @@ public class PayNotifyRecordController {
private final PayNotifyRecordService payNotifyRecordService;
private final PayChannelService payChannelService;
private final PayNotifyCallbakHandler alipayCallback;
private final PayNotifyCallbakHandler weChatCallback;
@@ -179,11 +189,25 @@ public class PayNotifyRecordController {
@XssCleanIgnore
@PostMapping("/wx/callbak")
@Operation(summary = "微信渠道支付回调", description = "微信渠道支付回调")
public String wxCallbak(HttpServletRequest request) {
String xmlMsg = HttpKit.readData(request);
log.info("微信订单回调信息:{}", xmlMsg);
Map<String, String> params = WxPayKit.xmlToMap(xmlMsg);
return weChatCallback.handle(params);
public ResponseEntity<String> wxCallbak(HttpServletRequest request) {
String body = HttpKit.readData(request);
log.info("微信 APIV3 订单回调信息:{}", body);
try {
PayChannel channel = payChannelService.getOne(Wrappers.<PayChannel>lambdaQuery()
.eq(PayChannel::getChannelId, PayChannelNameEnum.WEIXIN_MP.name()), false);
WeChatPayV3Config config = WeChatPayV3Config.from(channel);
String decryptData = WxPayKit.verifyNotify(request.getHeader("Wechatpay-Serial"), body,
request.getHeader("Wechatpay-Nonce"), request.getHeader("Wechatpay-Timestamp"),
request.getHeader("Wechatpay-Signature"), config.getApiV3Key(), config.platformCertInputStream());
Map<String, String> params = WeChatPayV3NotifyParser.parse(decryptData);
weChatCallback.handle(params);
return ResponseEntity.noContent().build();
} catch (Exception ex) {
log.warn("微信 APIV3 订单回调处理失败", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.contentType(MediaType.APPLICATION_JSON)
.body(JSONUtil.createObj().set("code", "FAIL").set("message", ex.getMessage()).toString());
}
}
/**
@@ -60,13 +60,16 @@ public abstract class AbstractPayOrderHandler implements PayOrderHandler {
PayChannel payChannel = preparePayParams();
ChannelPayApiConfigKit.put(payChannel);
createGoodsOrder(payGoodsOrder);
PayTradeOrder tradeOrder = createTradeOrder(payGoodsOrder);
Object result = pay(payGoodsOrder, tradeOrder);
updateOrder(payGoodsOrder, tradeOrder);
// 情况ttl
ChannelPayApiConfigKit.remove();
return result;
try {
createGoodsOrder(payGoodsOrder);
PayTradeOrder tradeOrder = createTradeOrder(payGoodsOrder);
Object result = pay(payGoodsOrder, tradeOrder);
updateOrder(payGoodsOrder, tradeOrder);
return result;
} finally {
// 清理 ThreadLocal
ChannelPayApiConfigKit.remove();
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2018-2026, lengleng All rights reserved.
* Copyright (c) 2018-2025, lengleng All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
@@ -21,13 +21,15 @@ import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.ijpay.core.enums.SignType;
import com.ijpay.core.enums.TradeType;
import com.ijpay.core.IJPayHttpResponse;
import com.ijpay.core.enums.RequestMethodEnum;
import com.ijpay.core.kit.WxPayKit;
import com.ijpay.wxpay.WxPayApi;
import com.ijpay.wxpay.WxPayApiConfig;
import com.ijpay.wxpay.WxPayApiConfigKit;
import com.ijpay.wxpay.model.UnifiedOrderModel;
import com.ijpay.wxpay.enums.WxDomainEnum;
import com.ijpay.wxpay.enums.v3.BasePayApiEnum;
import com.ijpay.wxpay.model.v3.Amount;
import com.ijpay.wxpay.model.v3.Payer;
import com.ijpay.wxpay.model.v3.UnifiedOrderModel;
import com.pig4cloud.pigx.common.core.util.WebUtils;
import com.pig4cloud.pigx.common.data.tenant.TenantContextHolder;
import com.pig4cloud.pigx.pay.entity.PayChannel;
@@ -39,13 +41,12 @@ import com.pig4cloud.pigx.pay.mapper.PayTradeOrderMapper;
import com.pig4cloud.pigx.pay.utils.ChannelPayApiConfigKit;
import com.pig4cloud.pigx.pay.utils.OrderStatusEnum;
import com.pig4cloud.pigx.pay.utils.PayChannelNameEnum;
import com.pig4cloud.pigx.pay.utils.WeChatPayV3Config;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* @author lengleng
* @date 2019-05-31
@@ -78,14 +79,8 @@ public class WeChatMpPayOrderHandler extends AbstractPayOrderHandler {
throw new IllegalArgumentException("微信公众号支付渠道配置为空");
}
JSONObject params = JSONUtil.parseObj(channel.getParam());
WxPayApiConfig wx = WxPayApiConfig.builder()
.appId(channel.getAppId())
.mchId(channel.getChannelMchId())
.partnerKey(params.getStr("partnerKey"))
.build();
WxPayApiConfigKit.setThreadLocalWxPayApiConfig(wx);
// 提前验证 APIV3 配置完整性,避免在写入订单后才抛出配置异常
WeChatPayV3Config.from(channel);
return channel;
}
@@ -100,7 +95,7 @@ public class WeChatMpPayOrderHandler extends AbstractPayOrderHandler {
tradeOrder.setOrderId(goodsOrder.getPayOrderId());
tradeOrder.setAmount(goodsOrder.getAmount());
tradeOrder.setChannelId(PayChannelNameEnum.WEIXIN_MP.getName());
tradeOrder.setChannelMchId(WxPayApiConfigKit.getWxPayApiConfig().getMchId());
tradeOrder.setChannelMchId(ChannelPayApiConfigKit.get().getChannelMchId());
tradeOrder.setClientIp(JakartaServletUtil.getClientIP(request));
tradeOrder.setCurrency("CNY");
tradeOrder.setStatus(OrderStatusEnum.INIT.getStatus());
@@ -116,32 +111,38 @@ public class WeChatMpPayOrderHandler extends AbstractPayOrderHandler {
*/
@Override
public Object pay(PayGoodsOrder goodsOrder, PayTradeOrder tradeOrder) {
String ip = JakartaServletUtil.getClientIP(request);
WxPayApiConfig wxPayApiConfig = WxPayApiConfigKit.getWxPayApiConfig();
// 判断是否是单体架构
Map<String, String> params = UnifiedOrderModel.builder()
.appid(wxPayApiConfig.getAppId())
.mch_id(wxPayApiConfig.getMchId())
.nonce_str(WxPayKit.generateStr())
.body(goodsOrder.getGoodsName())
WeChatPayV3Config config = WeChatPayV3Config.from(ChannelPayApiConfigKit.get());
String notifyUrl = String.format("%s/api/%s/notify/wx/callbak", ChannelPayApiConfigKit.get().getNotifyUrl(),
WebUtils.isMicro() ? "pay" : "admin");
UnifiedOrderModel orderModel = UnifiedOrderModel.builder()
.appid(config.getAppId())
.mchid(config.getMchId())
.description(goodsOrder.getGoodsName())
.attach(TenantContextHolder.getTenantId().toString())
.out_trade_no(String.valueOf(tradeOrder.getOrderId()))
.total_fee(goodsOrder.getAmount())
.spbill_create_ip(ip)
.notify_url(String.format("%s/api/%s/notify/wx/callbak", ChannelPayApiConfigKit.get().getNotifyUrl(),
WebUtils.isMicro() ? "pay" : "admin"))
.trade_type(TradeType.JSAPI.getTradeType())
.openid(goodsOrder.getUserId())
.build()
.createSign(wxPayApiConfig.getPartnerKey(), SignType.HMACSHA256);
.notify_url(notifyUrl)
.amount(Amount.builder().total(Integer.parseInt(goodsOrder.getAmount())).currency("CNY").build())
.payer(Payer.builder().openid(goodsOrder.getUserId()).build())
.build();
String xmlResult = WxPayApi.pushOrder(false, params);
log.info("微信统一下单返回 {}", xmlResult);
Map<String, String> resultMap = WxPayKit.xmlToMap(xmlResult);
String prepayId = resultMap.get("prepay_id");
return WxPayKit.prepayIdCreateSign(prepayId, wxPayApiConfig.getAppId(), wxPayApiConfig.getPartnerKey(),
SignType.HMACSHA256);
try {
IJPayHttpResponse response = WxPayApi.v3(RequestMethodEnum.POST, WxDomainEnum.CHINA.getDomain(),
BasePayApiEnum.JS_API_PAY.getUrl(), config.getMchId(), config.getMerchantSerialNo(), null,
config.getPrivateKey(), JSONUtil.toJsonStr(orderModel));
log.info("微信 APIV3 JSAPI 下单返回: status={}, body={}", response.getStatus(), response.getBody());
if (response.getStatus() < 200 || response.getStatus() >= 300) {
throw new IllegalStateException("微信支付 APIV3 下单失败: " + response.getBody());
}
JSONObject result = JSONUtil.parseObj(response.getBody());
String prepayId = result.getStr("prepay_id");
if (prepayId == null) {
throw new IllegalStateException("微信支付 APIV3 下单未返回 prepay_id");
}
return WxPayKit.jsApiCreateSign(config.getAppId(), prepayId, config.getPrivateKey());
} catch (Exception ex) {
throw new IllegalStateException("微信支付 APIV3 下单失败", ex);
}
}
/**
@@ -100,7 +100,7 @@ public class YungouosMergePayOrderHandler extends AbstractPayOrderHandler {
return MergePay.nativePay(String.valueOf(tradeOrder.getOrderId()), money, channel.getChannelMchId(),
tradeOrder.getBody(), "1", TenantContextHolder.getTenantId().toString(),
String.format("%s/api/%s/notify/merge/callbak", ChannelPayApiConfigKit.get().getNotifyUrl(), WebUtils.isMicro() ? "pay" : "admin"),
ChannelPayApiConfigKit.get().getReturnUrl(), "", "", "0", "pay", null, key);
ChannelPayApiConfigKit.get().getReturnUrl(), "", "", "0", null, null, key);
}
/**
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2018-2025, lengleng All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* Neither the name of the pig4cloud.com developer nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
* Author: lengleng (wangiegie@gmail.com)
*/
package com.pig4cloud.pigx.pay.utils;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.ijpay.core.enums.AuthTypeEnum;
import com.ijpay.core.kit.PayKit;
import com.pig4cloud.pigx.pay.entity.PayChannel;
import lombok.Getter;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
/**
* 微信支付 APIV3 渠道配置
*
* @author lengleng
* @date 2026-05-10
*/
@Getter
public class WeChatPayV3Config {
/**
* 公众号 AppID。
*/
private final String appId;
/**
* 微信支付商户号(channelMchId)。
*/
private final String mchId;
/**
* APIv3 密钥,32 位字符串,用于回调解密。
*/
private final String apiV3Key;
/**
* 商户 API 证书序列号,用于签名请求头。
*/
private final String merchantSerialNo;
/**
* 商户私钥 PEM 文本,换行符已从 \n 还原为真实换行。
*/
private final String privateKeyContent;
/**
* 微信平台证书 PEM 文本,换行符已从 \n 还原为真实换行。
*/
private final String platformCertContent;
/**
* 解析后的商户私钥对象,用于 APIV3 签名。
*/
private final PrivateKey privateKey;
private WeChatPayV3Config(String appId, String mchId, String apiV3Key, String merchantSerialNo,
String privateKeyContent, String platformCertContent, PrivateKey privateKey) {
this.appId = appId;
this.mchId = mchId;
this.apiV3Key = apiV3Key;
this.merchantSerialNo = merchantSerialNo;
this.privateKeyContent = privateKeyContent;
this.platformCertContent = platformCertContent;
this.privateKey = privateKey;
}
/**
* 从渠道配置构建 APIV3 配置对象,并验证必填字段。
*
* @param channel 支付渠道配置,不允许为空;param JSON 需含 apiV3Key / merchantSerialNo / privateKey / platformCert
* @return 解析完成的配置对象
* @throws IllegalArgumentException 配置缺失或私钥解析失败
*/
public static WeChatPayV3Config from(PayChannel channel) {
if (channel == null) {
throw new IllegalArgumentException("微信公众号支付渠道配置为空");
}
JSONObject params = JSONUtil.parseObj(channel.getParam());
String appId = require(channel.getAppId(), "appId");
String mchId = require(channel.getChannelMchId(), "channelMchId");
String apiV3Key = require(params.getStr("apiV3Key"), "param.apiV3Key");
String merchantSerialNo = require(params.getStr("merchantSerialNo"), "param.merchantSerialNo");
String privateKeyContent = normalizePem(require(params.getStr("privateKey"), "param.privateKey"));
String platformCertContent = normalizePem(require(params.getStr("platformCert"), "param.platformCert"));
try {
PrivateKey privateKey = PayKit.getPrivateKeyByKeyContent(privateKeyContent, AuthTypeEnum.RSA.getCode());
return new WeChatPayV3Config(appId, mchId, apiV3Key, merchantSerialNo, privateKeyContent,
platformCertContent, privateKey);
} catch (Exception ex) {
throw new IllegalArgumentException("微信支付 APIV3 私钥解析失败", ex);
}
}
/**
* 返回平台证书内容的字节流,用于传入 WxPayKit.verifyNotify 验签。
*
* @return 平台证书 UTF-8 字节流,每次调用返回新实例
*/
public InputStream platformCertInputStream() {
return new ByteArrayInputStream(platformCertContent.getBytes(StandardCharsets.UTF_8));
}
static String normalizePem(String value) {
return value.replace("\\n", "\n").trim();
}
private static String require(String value, String field) {
if (StrUtil.isBlank(value)) {
throw new IllegalArgumentException("微信支付 APIV3 配置缺失: " + field);
}
return value;
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2018-2025, lengleng All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* Neither the name of the pig4cloud.com developer nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
* Author: lengleng (wangiegie@gmail.com)
*/
package com.pig4cloud.pigx.pay.utils;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 微信支付 APIV3 回调报文适配器
*
* @author lengleng
* @date 2026-05-10
*/
public final class WeChatPayV3NotifyParser {
private WeChatPayV3NotifyParser() {
}
/**
* 将微信 APIV3 回调解密后的 JSON 转换为旧版回调处理器兼容的 Map。
* <p>trade_state 为 SUCCESS 时 result_code=SUCCESS,否则为 FAIL。
*
* @param decryptData WxPayKit.verifyNotify 解密后的 JSON 字符串,不允许为空
* @return 包含 out_trade_no / result_code / openid / total_fee 等字段的参数 Map
*/
public static Map<String, String> parse(String decryptData) {
JSONObject data = JSONUtil.parseObj(decryptData);
Map<String, String> params = new LinkedHashMap<>();
String tradeState = data.getStr("trade_state");
params.put(PayConstants.OUT_TRADE_NO, data.getStr("out_trade_no"));
params.put("transaction_id", data.getStr("transaction_id"));
params.put("attach", data.getStr("attach"));
params.put("trade_state", tradeState);
params.put("trade_state_desc", data.getStr("trade_state_desc"));
params.put("success_time", data.getStr("success_time"));
params.put(PayConstants.RESULT_CODE, StrUtil.equals("SUCCESS", tradeState) ? "SUCCESS" : "FAIL");
params.put("err_code", tradeState);
params.put("err_code_des", data.getStr("trade_state_desc"));
JSONObject payer = data.getJSONObject("payer");
if (payer != null) {
params.put("openid", payer.getStr("openid"));
}
JSONObject amount = data.getJSONObject("amount");
if (amount != null) {
params.put("total_fee", amount.getStr("total"));
params.put("payer_total", amount.getStr("payer_total"));
params.put("currency", amount.getStr("currency"));
params.put("payer_currency", amount.getStr("payer_currency"));
}
return params;
}
}
@@ -2,7 +2,7 @@
<!--
~
~ Copyright (c) 2018-2026, lengleng All rights reserved.
~ Copyright (c) 2018-2025, lengleng All rights reserved.
~
~ Redistribution and use in source and binary forms, with or without
~ modification, are permitted provided that the following conditions are met:
@@ -2,7 +2,7 @@
<!--
~
~ Copyright (c) 2018-2026, lengleng All rights reserved.
~ Copyright (c) 2018-2025, lengleng All rights reserved.
~
~ Redistribution and use in source and binary forms, with or without
~ modification, are permitted provided that the following conditions are met:
@@ -2,7 +2,7 @@
<!--
~
~ Copyright (c) 2018-2026, lengleng All rights reserved.
~ Copyright (c) 2018-2025, lengleng All rights reserved.
~
~ Redistribution and use in source and binary forms, with or without
~ modification, are permitted provided that the following conditions are met:
@@ -2,7 +2,7 @@
<!--
~
~ Copyright (c) 2018-2026, lengleng All rights reserved.
~ Copyright (c) 2018-2025, lengleng All rights reserved.
~
~ Redistribution and use in source and binary forms, with or without
~ modification, are permitted provided that the following conditions are met:
@@ -2,7 +2,7 @@
<!--
~
~ Copyright (c) 2018-2026, lengleng All rights reserved.
~ Copyright (c) 2018-2025, lengleng All rights reserved.
~
~ Redistribution and use in source and binary forms, with or without
~ modification, are permitted provided that the following conditions are met: