From d1c4d003f62a7bd4bc762f2fc62b97726e247e0c Mon Sep 17 00:00:00 2001 From: xuhy <3313886187@qq.com> Date: 星期五, 19 九月 2025 18:20:54 +0800 Subject: [PATCH] AI对接,微信小程序支付 --- /dev/null | 57 --- ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/JsapiPrepay.java | 118 +++++++ ruoyi-admin/src/main/resources/application-test.yml | 25 + ruoyi-system/pom.xml | 8 ruoyi-system/src/main/java/com/ruoyi/system/wxPay/model/V3.java | 31 + ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/WXPayUtility.java | 441 +++++++++++++++++++++++++++ ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/WxV3Pay.java | 108 ++++-- ruoyi-applet/src/main/java/com/ruoyi/web/controller/api/WxPayController.java | 134 ++------ 8 files changed, 721 insertions(+), 201 deletions(-) diff --git a/ruoyi-admin/src/main/resources/application-test.yml b/ruoyi-admin/src/main/resources/application-test.yml index e94910b..ad0964c 100644 --- a/ruoyi-admin/src/main/resources/application-test.yml +++ b/ruoyi-admin/src/main/resources/application-test.yml @@ -214,4 +214,27 @@ priKeyStr: C:\Users\Admin\Desktop\test\OP00000003_private_key.pem lklNotifyCerStr: C:\Users\Admin\Desktop\test\lkl-apigw-v2.cer sm4Key: LHo55AjrT4aDhAIBZhb5KQ== - serverUrl: https://test.wsmsd.cn/ \ No newline at end of file + serverUrl: https://test.wsmsd.cn/ +payment: + wx: + # 微信appid + appId: wxa17e8d1331e50934 + # 微信商户号 + mchId: 1721757915 + # 秘钥 + secretId: 79c234527fd3b6553679d52be5e29b19 + v3: + # 加签串 + apiKey: V7mKp9qL2Rs4jU6tX8wZ0bC3eF5hN1yD4gA + # 加签证书地址 + privateKeyPath: D:/app/cert/weixin/apiclient_key.pem + # 微信支付公钥id + publicKeyId: PUB_KEY_ID_0117217579152025091800181718000000 + # 微信支付公钥证书 + publicKeyPath: D:/app/cert/weixin/apiclient_cert.pem + # 证书序列号 + mchSerialNo: 39C7F6152E38A62B5786634D5C1F984FB5A38AD5 + # 支付成功回调地址 + notifyPayUrl: http://221.182.45.100/wx/pay/notify + # 支付退款回调地址 + notifyRefundUrl: http://221.182.45.100/wx/refund/notify \ No newline at end of file diff --git a/ruoyi-applet/src/main/java/com/ruoyi/web/controller/api/WxPayController.java b/ruoyi-applet/src/main/java/com/ruoyi/web/controller/api/WxPayController.java index d3c5629..8e049c0 100644 --- a/ruoyi-applet/src/main/java/com/ruoyi/web/controller/api/WxPayController.java +++ b/ruoyi-applet/src/main/java/com/ruoyi/web/controller/api/WxPayController.java @@ -1,8 +1,7 @@ package com.ruoyi.web.controller.api; +import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.core.toolkit.Wrappers; -import com.fasterxml.jackson.core.type.TypeReference; -import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.utils.CodeGenerateUtils; import com.ruoyi.framework.web.service.TokenService; @@ -14,12 +13,11 @@ import com.ruoyi.system.service.TSysInspectionService; import com.ruoyi.system.service.TSysOtherConfigService; import com.ruoyi.system.service.TSysPayRecordService; -import com.ruoyi.system.wxPay.enums.RefundEnum; import com.ruoyi.system.wxPay.enums.TradeStateEnum; import com.ruoyi.system.wxPay.model.WeixinPayProperties; -import com.ruoyi.system.wxPay.model.WxPaymentRefundModel; import com.ruoyi.system.wxPay.utils.WxTimeUtils; import com.ruoyi.system.wxPay.utils.WxV3Pay; +import com.wechat.pay.contrib.apache.httpclient.util.AesUtil; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; @@ -27,8 +25,11 @@ import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; -import java.io.IOException; +import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.PrintWriter; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Objects; @@ -73,7 +74,7 @@ String userId = tokenService.getLoginUserApplet().getUserId(); TSysAppUser sysAppUser = sysAppUserService.getById(userId); // 价格 - Integer totalPrice = sysOtherConfig.getAiPrice().multiply(new BigDecimal(100)).intValue(); + Long totalPrice = sysOtherConfig.getAiPrice().multiply(new BigDecimal(100)).longValue(); // 生成订单号 String orderNo = CodeGenerateUtils.generateOrderSn(); // 查询用户信息 用户openid @@ -81,62 +82,41 @@ // 存储支付记录 sysPayRecordService.saveData(orderNo, userId, sysOtherConfig.getAiPrice(), 1); // 调用支付方法 - Map<String, Object> result = wxV3Pay.jsApi(orderNo, totalPrice, openId,"AI检测报告支付"); + Map<String, Object> result = wxV3Pay.jsApi(orderNo, totalPrice, openId,"AI检测报告支付",""); log.info("支付参数:{}", result); return R.ok(result); } /** - * 微信v3支付-订单退款 - * - * @return - */ - @ApiOperation("订单退款") - @PostMapping(value = "refund-order") - public AjaxResult<String> refundOrder() { - Map<String, Object> result = wxV3Pay.refund(new WxPaymentRefundModel()); - log.info("退款结果:{}", result); - // 微信支付退款单号 - String refund_id = result.get("refund_id").toString(); - // 商户退款单号 - String out_refund_no = result.get("out_refund_no").toString(); - // 微信支付订单号 - String transaction_id = result.get("transaction_id").toString(); - // 商户订单号 tradeNo - String out_trade_no = result.get("out_trade_no").toString(); - // 退款成功时间 - String success_time = Objects.nonNull(result.get("success_time")) ? result.get("success_time").toString() : null; - // 退款状态 RefundEnum - String status = result.get("status").toString(); - // TODO 退款业务处理 - return AjaxResult.success(); - } - /** * 支付回调 */ @PostMapping("pay/notify") @ApiOperation("订单回调") - public void payNotify(HttpServletRequest request) throws Exception { + public void payNotify(HttpServletRequest request, HttpServletResponse response) throws Exception { try { - Map<String, Object> params = wxV3Pay.verifyNotify(request, new TypeReference<Map<String, Object>>() {}); - log.info("支付回调:{}", params); - // 商户订单号 - String tradeNo = params.get("out_trade_no").toString(); - // 交易状态 - String trade_state = params.get("trade_state").toString(); - // 交易状态描述 - String trade_state_desc = params.get("trade_state_desc").toString(); - // 微信支付订单号 - String transaction_id = params.get("transaction_id").toString(); - // 支付完成时间 - // 时间不对的话,可以调用 WxTimeUtils.toRfc3339Date(success_time)转换一下 - String success_time = params.get("success_time").toString(); - // 附加数据 - Integer attach = Integer.parseInt(params.get("attach").toString()); + System.err.println("微信回调"); + BufferedReader reader = request.getReader(); + StringBuilder requestBody = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + requestBody.append(line); + } + System.err.println("全部请求体" + requestBody); + JSONObject jsonObject = JSONObject.parseObject(requestBody.toString()); + JSONObject resource = jsonObject.getJSONObject("resource"); + + AesUtil aesUtil = new AesUtil(weixinPayProperties.getV3().getApiKey().getBytes(StandardCharsets.UTF_8)); + String decryptedData = aesUtil.decryptToString(resource.getString("associated_data").getBytes(StandardCharsets.UTF_8), resource.getString("nonce").getBytes(StandardCharsets.UTF_8), + resource.getString("ciphertext")); + System.err.println("微信解密的字符串信息" + decryptedData); + JSONObject jsonInfo = JSONObject.parse(decryptedData); + String out_trade_no = jsonInfo.getString("out_trade_no"); + String transaction_id = jsonInfo.getString("transaction_id"); + String trade_state = jsonInfo.getString("trade_state"); + String success_time = jsonInfo.getString("success_time"); // 查询订单 TSysPayRecord sysPayRecord = sysPayRecordService.getOne(Wrappers.lambdaQuery(TSysPayRecord.class) - .eq(TSysPayRecord::getOrderNo, tradeNo).last("LIMIT 1")); - // 处理订单 + .eq(TSysPayRecord::getOrderNo, out_trade_no).last("LIMIT 1")); if (trade_state.equals(TradeStateEnum.SUCCESS.name())) { log.info("回调成功"); // 订单号查询订单 @@ -150,60 +130,14 @@ sysInspection.setIsPay(1); sysInspectionService.updateById(sysInspection); - wxV3Pay.ack(); + PrintWriter out = response.getWriter(); + out.print("SUCCESS"); + out.flush(); + out.close(); } - wxV3Pay.ack(); } catch (Exception e) { log.error("支付回调异常:{}", e, e); wxV3Pay.ack(false, e.getMessage()); } } - - /** - * 支付回调成功后 - */ - @PostMapping("pay/ack") - public void ack(){ - try { - wxV3Pay.ack(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - - - - /** - * 退款回调 - */ - @PostMapping("refund/notify") - public void refundNotify(HttpServletRequest request) throws IOException { - try { - Map<String, Object> params = wxV3Pay.verifyNotify(request, new TypeReference<Map<String, Object>>() { - }); - // 商户订单号 - String out_trade_no = params.get("out_trade_no").toString(); - // 商户退款单号 - String out_refund_no = params.get("out_refund_no").toString(); - // 微信支付订单号 - String transaction_id = params.get("transaction_id").toString(); - // 微信支付退款单号 - String refund_id = params.get("refund_id").toString(); - // 退款状态 - String tradeState = params.get("refund_status").toString(); - // 退款成功时间 - // 时间不对的话,可以调用 WxTimeUtils.toRfc3339Date(success_time)转换一下 - String success_time = params.get("success_time").toString(); - if (tradeState.equals(RefundEnum.SUCCESS.name())) { - wxV3Pay.ack(); - } else { - wxV3Pay.ack(false, "不是成功的退款状态"); - } - } catch (Exception e) { - e.printStackTrace(); - wxV3Pay.ack(false, e.getMessage()); - } - } - } diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/config/WxConfig.java b/ruoyi-common/src/main/java/com/ruoyi/common/config/WxConfig.java deleted file mode 100644 index 770412a..0000000 --- a/ruoyi-common/src/main/java/com/ruoyi/common/config/WxConfig.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.ruoyi.common.config; - - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - - -/** - * 项目中需继承此类 - * - * @author lihen - */ -@Data -@Component -@ConfigurationProperties(prefix = "wx.config") -public class WxConfig { - - /** - * 获取 App ID - * - * @return App ID - */ - @JsonProperty("appId") - private String appId; - - /** - * 获取 Secret - * - * @return Secret - */ - @JsonProperty("secret") - private String secret; - - -} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/WxAppletTools.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/WxAppletTools.java deleted file mode 100644 index 7c6ee85..0000000 --- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/WxAppletTools.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.ruoyi.common.utils; - -import com.alibaba.fastjson2.JSONObject; -import com.ruoyi.common.config.WxConfig; -import com.ruoyi.common.core.redis.RedisCache; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.client.RestTemplate; - -import java.text.MessageFormat; - -/** - * @author liheng - * @ClassName WxAppletTools - * @Description - * @date 2020-12-04 13:55 - */ -@Slf4j -@Component -public class WxAppletTools { - @Autowired - private RedisCache redisCache; - @Autowired - private RestTemplate restTemplate; - @Autowired - private WxConfig wxConfig; - private final static String ACCESSTOKEN_CACHE_KEY = "accessToken"; - /** - * 请求参数 - * 属性 类型 默认值 必填 说明 - * appid string 是 小程序 appId - * secret string 是 小程序 appSecret - * js_code string 是 登录时获取的 code - * grant_type string 是 授权类型,此处只需填写 authorization_cod - * <p> - * 返回值: - * <p> - * 属性 类型 说明 - * openid string 用户唯一标识 - * session_key string 会话密钥 - * unionid string 用户在开放平台的唯一标识符,若当前小程序已绑定到微信开放平台帐号下会返回,详见 UnionID 机制说明。 - * errcode number 错误码 - * errmsg string 错误信息 - */ - private static final String JSCODE_2_SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session?appid={0}&secret={1}&js_code={2}&grant_type=authorization_code"; - - - /** - * 请求参数 - * 属性 类型 默认值 必填 说明 - * grant_type string 是 填写 client_credential - * appid string 是 小程序唯一凭证,即 AppID,可在「微信公众平台 - 设置 - 开发设置」页中获得。(需要已经成为开发者,且帐号没有异常状态) - * secret string 是 小程序唯一凭证密钥,即 AppSecret,获取方式同 appid - * 返回值 - * Object - * 返回的 JSON 数据包 - * <p> - * 属性 类型 说明 - * access_token string 获取到的凭证 - * expires_in number 凭证有效时间,单位:秒。目前是7200秒之内的值。 - * errcode number 错误码 - * errmsg string 错误信息 - */ - private static String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={0}&secret={1}"; - - /** - * @return - */ - public String getAccessToken() { - String requestUrl = MessageFormat.format(ACCESS_TOKEN_URL, wxConfig.getAppId(), wxConfig.getSecret()); - String respBody = restTemplate.getForEntity(requestUrl, String.class).getBody(); - JSONObject jsonObject = JSONObject.parseObject(respBody); - return jsonObject.getString("access_token"); - } -} diff --git a/ruoyi-system/pom.xml b/ruoyi-system/pom.xml index cca3bb2..5c28a56 100644 --- a/ruoyi-system/pom.xml +++ b/ruoyi-system/pom.xml @@ -96,8 +96,14 @@ </dependency> <dependency> <groupId>com.github.wechatpay-apiv3</groupId> + <artifactId>wechatpay-java-core</artifactId> + <version>0.2.12</version> + <scope>compile</scope> + </dependency> + <dependency> + <groupId>com.github.wechatpay-apiv3</groupId> <artifactId>wechatpay-apache-httpclient</artifactId> - <version>0.4.3</version> + <version>0.4.9</version> </dependency> </dependencies> diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/task/utils/TaskUtil.java b/ruoyi-system/src/main/java/com/ruoyi/system/task/utils/TaskUtil.java deleted file mode 100644 index a0cec7c..0000000 --- a/ruoyi-system/src/main/java/com/ruoyi/system/task/utils/TaskUtil.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.ruoyi.system.task.utils; - - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.ruoyi.common.constant.CacheConstants; -import com.ruoyi.common.core.redis.RedisCache; -import com.ruoyi.common.utils.uuid.UUID; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.temporal.ChronoUnit; -import java.util.List; - -/** - * @author zhibing.pu - * @date 2023/7/11 8:39 - */ -@Component -public class TaskUtil { - @Autowired - RedisCache redisCache; - // 用于更新违约金账单 - // 每分钟执行一次的定时任务 - - @Scheduled(cron = "0 0 0 * * ?") - public void dayOfProportionBill() { - try { - } catch (Exception e) { - e.printStackTrace(); - } - } - - public static void main(String[] args) { - -// LocalDateTime now = LocalDateTime.now().minusMonths(1).withDayOfMonth(31); -// System.err.println(now); -// LocalDateTime now2 = now.plusMonths(1); -// System.err.println(now2); -// -// LocalDateTime now1 = LocalDateTime.now(); -// long days = ChronoUnit.DAYS.between(now, now1); -// long days2 = ChronoUnit.DAYS.between(now.plusDays(1), now1); -// -// System.err.println(days); -// System.err.println(days2); -// LocalDateTime endTime = now.with(TemporalAdjusters.lastDayOfMonth()).withSecond(59).withHour(23).withMinute(59); -// -// System.err.println(endTime); - - } - -} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/model/V3.java b/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/model/V3.java index cdcbfc9..18062cb 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/model/V3.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/model/V3.java @@ -28,6 +28,14 @@ */ private String privateKeyPath; /** + * 微信支付公钥id + */ + private String publicKeyId; + /** + * 微信支付公钥证书 apiclient_cert.pem + */ + private String publicKeyPath; + /** * 商户证书序列号 */ private String mchSerialNo; @@ -74,4 +82,27 @@ } return new ByteArrayInputStream(certData); } + + public InputStream getPublicKeyStream() { + // 需要证书释放 + byte[] certData; +// InputStream certStream = Thread.currentThread().getContextClassLoader().getResourceAsStream(this.privateKeyPath); + InputStream certStream = null; + try { + certStream = new FileInputStream(this.publicKeyPath); + certData = IOUtils.toByteArray(certStream); + } catch (IOException e) { + e.printStackTrace(); + throw new RuntimeException("公钥文件未找到"); + }finally { + if(null != certStream){ + try { + certStream.close(); + } catch (IOException e) { + log.error("公钥流关闭异常"); + } + } + } + return new ByteArrayInputStream(certData); + } } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/JsapiPrepay.java b/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/JsapiPrepay.java new file mode 100644 index 0000000..ded7b52 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/JsapiPrepay.java @@ -0,0 +1,118 @@ +package com.ruoyi.system.wxPay.utils; + + +import com.google.gson.annotations.SerializedName; +import okhttp3.*; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.security.PrivateKey; +import java.security.PublicKey; + +/** + * JSAPI下单 + */ +public class JsapiPrepay { + private static String HOST = "https://api.mch.weixin.qq.com"; + private static String METHOD = "POST"; + private static String PATH = "/v3/pay/transactions/jsapi"; + private final String mchId; + private final String certificateSerialNo; + private final PrivateKey privateKey; + private final String wechatPayPublicKeyId; + private final PublicKey wechatPayPublicKey; + + public JsapiPrepay(String mchId, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) { + this.mchId = mchId; + this.certificateSerialNo = certificateSerialNo; + this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath); + this.wechatPayPublicKeyId = wechatPayPublicKeyId; + this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath); + } + + public DirectAPIv3JsapiPrepayResponse run(DirectAPIv3JsapiPrepayRequest request) { + String uri = PATH; + String reqBody = WXPayUtility.toJson(request); + + Request.Builder reqBuilder = new Request.Builder().url(HOST + uri); + reqBuilder.addHeader("Accept", "application/json"); + reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId); + reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchId, certificateSerialNo,privateKey, METHOD, uri, reqBody)); + reqBuilder.addHeader("Content-Type", "application/json"); + RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody); + reqBuilder.method(METHOD, requestBody); + Request httpRequest = reqBuilder.build(); + + // 发送HTTP请求 + OkHttpClient client = new OkHttpClient.Builder().build(); + try (Response httpResponse = client.newCall(httpRequest).execute()) { + String respBody = WXPayUtility.extractBody(httpResponse); + if (httpResponse.code() >= 200 && httpResponse.code() < 300) { + // 2XX 成功,验证应答签名 + WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey, + httpResponse.headers(), respBody); + + // 从HTTP应答报文构建返回数据 + return WXPayUtility.fromJson(respBody, DirectAPIv3JsapiPrepayResponse.class); + } else { + throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers()); + } + } catch (IOException e) { + throw new UncheckedIOException("Sending request to " + uri + " failed.", e); + } + } + public static class DirectAPIv3JsapiPrepayRequest { + @SerializedName("appid") + public String appid; + + @SerializedName("mchid") + public String mchid; + + @SerializedName("description") + public String description; + + @SerializedName("out_trade_no") + public String outTradeNo; + + @SerializedName("time_expire") + public String timeExpire; + + @SerializedName("attach") + public String attach; + + @SerializedName("notify_url") + public String notifyUrl; + + @SerializedName("goods_tag") + public String goodsTag; + + @SerializedName("support_fapiao") + public Boolean supportFapiao; + + @SerializedName("amount") + public CommonAmountInfo amount; + + @SerializedName("payer") + public JsapiReqPayerInfo payer; + + } + + public static class DirectAPIv3JsapiPrepayResponse { + @SerializedName("prepay_id") + public String prepayId; + } + + public static class CommonAmountInfo { + @SerializedName("total") + public Long total; + + @SerializedName("currency") + public String currency; + } + + public static class JsapiReqPayerInfo { + @SerializedName("openid") + public String openid; + } + +} \ No newline at end of file diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/WXPayUtility.java b/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/WXPayUtility.java new file mode 100644 index 0000000..834edae --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/WXPayUtility.java @@ -0,0 +1,441 @@ +package com.ruoyi.system.wxPay.utils; + +import com.google.gson.*; +import com.google.gson.annotations.Expose; +import com.wechat.pay.java.core.util.GsonUtil; +import okhttp3.Headers; +import okhttp3.Response; +import okio.BufferedSource; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; + +public class WXPayUtility { + private static final Gson gson = new GsonBuilder() + .disableHtmlEscaping() + .addSerializationExclusionStrategy(new ExclusionStrategy() { + @Override + public boolean shouldSkipField(FieldAttributes fieldAttributes) { + final Expose expose = fieldAttributes.getAnnotation(Expose.class); + return expose != null && !expose.serialize(); + } + + @Override + public boolean shouldSkipClass(Class<?> aClass) { + return false; + } + }) + .addDeserializationExclusionStrategy(new ExclusionStrategy() { + @Override + public boolean shouldSkipField(FieldAttributes fieldAttributes) { + final Expose expose = fieldAttributes.getAnnotation(Expose.class); + return expose != null && !expose.deserialize(); + } + + @Override + public boolean shouldSkipClass(Class<?> aClass) { + return false; + } + }) + .create(); + private static final char[] SYMBOLS = + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); + private static final SecureRandom random = new SecureRandom(); + + /** + * 将 Object 转换为 JSON 字符串 + */ + public static String toJson(Object object) { + return gson.toJson(object); + } + + /** + * 将 JSON 字符串解析为特定类型的实例 + */ + public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException { + return gson.fromJson(json, classOfT); + } + + /** + * 从公私钥文件路径中读取文件内容 + * + * @param keyPath 文件路径 + * @return 文件内容 + */ + private static String readKeyStringFromPath(String keyPath) { + try { + return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * 读取 PKCS#8 格式的私钥字符串并加载为私钥对象 + * + * @param keyString 私钥文件内容,以 -----BEGIN PRIVATE KEY----- 开头 + * @return PrivateKey 对象 + */ + public static PrivateKey loadPrivateKeyFromString(String keyString) { + try { + keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + return KeyFactory.getInstance("RSA").generatePrivate( + new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString))); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException(e); + } catch (InvalidKeySpecException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * 从 PKCS#8 格式的私钥文件中加载私钥 + * + * @param keyPath 私钥文件路径 + * @return PrivateKey 对象 + */ + public static PrivateKey loadPrivateKeyFromPath(String keyPath) { + return loadPrivateKeyFromString(readKeyStringFromPath(keyPath)); + } + + /** + * 读取 PKCS#8 格式的公钥字符串并加载为公钥对象 + * + * @param keyString 公钥文件内容,以 -----BEGIN PUBLIC KEY----- 开头 + * @return PublicKey 对象 + */ + public static PublicKey loadPublicKeyFromString(String keyString) { + try { + keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s+", ""); + return KeyFactory.getInstance("RSA").generatePublic( + new X509EncodedKeySpec(Base64.getDecoder().decode(keyString))); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException(e); + } catch (InvalidKeySpecException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * 从 PKCS#8 格式的公钥文件中加载公钥 + * + * @param keyPath 公钥文件路径 + * @return PublicKey 对象 + */ + public static PublicKey loadPublicKeyFromPath(String keyPath) { + return loadPublicKeyFromString(readKeyStringFromPath(keyPath)); + } + + /** + * 创建指定长度的随机字符串,字符集为[0-9a-zA-Z],可用于安全相关用途 + */ + public static String createNonce(int length) { + char[] buf = new char[length]; + for (int i = 0; i < length; ++i) { + buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)]; + } + return new String(buf); + } + + /** + * 使用公钥按照 RSA_PKCS1_OAEP_PADDING 算法进行加密 + * + * @param publicKey 加密用公钥对象 + * @param plaintext 待加密明文 + * @return 加密后密文 + */ + public static String encrypt(PublicKey publicKey, String plaintext) { + final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"; + + try { + Cipher cipher = Cipher.getInstance(transformation); + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + throw new IllegalArgumentException("The current Java environment does not support " + transformation, e); + } catch (InvalidKeyException e) { + throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e); + } catch (BadPaddingException | IllegalBlockSizeException e) { + throw new IllegalArgumentException("Plaintext is too long", e); + } + } + + /** + * 使用私钥按照指定算法进行签名 + * + * @param message 待签名串 + * @param algorithm 签名算法,如 SHA256withRSA + * @param privateKey 签名用私钥对象 + * @return 签名结果 + */ + public static String sign(String message, String algorithm, PrivateKey privateKey) { + byte[] sign; + try { + Signature signature = Signature.getInstance(algorithm); + signature.initSign(privateKey); + signature.update(message.getBytes(StandardCharsets.UTF_8)); + sign = signature.sign(); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e); + } catch (InvalidKeyException e) { + throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e); + } catch (SignatureException e) { + throw new RuntimeException("An error occurred during the sign process.", e); + } + return Base64.getEncoder().encodeToString(sign); + } + + /** + * 使用公钥按照特定算法验证签名 + * + * @param message 待签名串 + * @param signature 待验证的签名内容 + * @param algorithm 签名算法,如:SHA256withRSA + * @param publicKey 验签用公钥对象 + * @return 签名验证是否通过 + */ + public static boolean verify(String message, String signature, String algorithm, + PublicKey publicKey) { + try { + Signature sign = Signature.getInstance(algorithm); + sign.initVerify(publicKey); + sign.update(message.getBytes(StandardCharsets.UTF_8)); + return sign.verify(Base64.getDecoder().decode(signature)); + } catch (SignatureException e) { + return false; + } catch (InvalidKeyException e) { + throw new IllegalArgumentException("verify uses an illegal publickey.", e); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e); + } + } + + /** + * 根据微信支付APIv3请求签名规则构造 Authorization 签名 + * + * @param mchid 商户号 + * @param certificateSerialNo 商户API证书序列号 + * @param privateKey 商户API证书私钥 + * @param method 请求接口的HTTP方法,请使用全大写表述,如 GET、POST、PUT、DELETE + * @param uri 请求接口的URL + * @param body 请求接口的Body + * @return 构造好的微信支付APIv3 Authorization 头 + */ + public static String buildAuthorization(String mchid, String certificateSerialNo, + PrivateKey privateKey, + String method, String uri, String body) { + String nonce = createNonce(32); + long timestamp = Instant.now().getEpochSecond(); + + String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce, + body == null ? "" : body); + + String signature = sign(message, "SHA256withRSA", privateKey); + + return String.format( + "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," + + "timestamp=\"%d\",serial_no=\"%s\"", + mchid, nonce, signature, timestamp, certificateSerialNo); + } + + /** + * 对参数进行 URL 编码 + * + * @param content 参数内容 + * @return 编码后的内容 + */ + public static String urlEncode(String content) { + try { + return URLEncoder.encode(content, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + /** + * 对参数Map进行 URL 编码,生成 QueryString + * + * @param params Query参数Map + * @return QueryString + */ + public static String urlEncode(Map<String, Object> params) { + if (params == null || params.isEmpty()) { + return ""; + } + + int index = 0; + StringBuilder result = new StringBuilder(); + for (Map.Entry<String, Object> entry : params.entrySet()) { + result.append(entry.getKey()) + .append("=") + .append(urlEncode(entry.getValue().toString())); + index++; + if (index < params.size()) { + result.append("&"); + } + } + return result.toString(); + } + + /** + * 从应答中提取 Body + * + * @param response HTTP 请求应答对象 + * @return 应答中的Body内容,Body为空时返回空字符串 + */ + public static String extractBody(Response response) { + if (response.body() == null) { + return ""; + } + + try { + BufferedSource source = response.body().source(); + return source.readUtf8(); + } catch (IOException e) { + throw new RuntimeException(String.format("An error occurred during reading response body. Status: %d", response.code()), e); + } + } + + /** + * 根据微信支付APIv3应答验签规则对应答签名进行验证,验证不通过时抛出异常 + * + * @param wechatpayPublicKeyId 微信支付公钥ID + * @param wechatpayPublicKey 微信支付公钥对象 + * @param headers 微信支付应答 Header 列表 + * @param body 微信支付应答 Body + */ + public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey, + Headers headers, + String body) { + String timestamp = headers.get("Wechatpay-Timestamp"); + try { + Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp)); + // 拒绝过期请求 + if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) { + throw new IllegalArgumentException( + String.format("Validate http response,timestamp[%s] of httpResponse is expires, " + + "request-id[%s]", + timestamp, headers.get("Request-ID"))); + } + } catch (DateTimeException | NumberFormatException e) { + throw new IllegalArgumentException( + String.format("Validate http response,timestamp[%s] of httpResponse is invalid, " + + "request-id[%s]", timestamp, + headers.get("Request-ID"))); + } + String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"), + body == null ? "" : body); + String serialNumber = headers.get("Wechatpay-Serial"); + if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) { + throw new IllegalArgumentException( + String.format("Invalid Wechatpay-Serial, Local: %s, Remote: %s", wechatpayPublicKeyId, + serialNumber)); + } + String signature = headers.get("Wechatpay-Signature"); + + boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey); + if (!success) { + throw new IllegalArgumentException( + String.format("Validate response failed,the WechatPay signature is incorrect.%n" + + "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]", + headers.get("Request-ID"), headers, body)); + } + } + + /** + * 微信支付API错误异常,发送HTTP请求成功,但返回状态码不是 2XX 时抛出本异常 + */ + public static class ApiException extends RuntimeException { + private static final long serialVersionUID = 2261086748874802175L; + + private final int statusCode; + private final String body; + private final Headers headers; + private final String errorCode; + private final String errorMessage; + + public ApiException(int statusCode, String body, Headers headers) { + super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode, body, headers)); + this.statusCode = statusCode; + this.body = body; + this.headers = headers; + + if (body != null && !body.isEmpty()) { + JsonElement code; + JsonElement message; + + try { + JsonObject jsonObject = GsonUtil.getGson().fromJson(body, JsonObject.class); + code = jsonObject.get("code"); + message = jsonObject.get("message"); + } catch (JsonSyntaxException ignored) { + code = null; + message = null; + } + this.errorCode = code == null ? null : code.getAsString(); + this.errorMessage = message == null ? null : message.getAsString(); + } else { + this.errorCode = null; + this.errorMessage = null; + } + } + + /** + * 获取 HTTP 应答状态码 + */ + public int getStatusCode() { + return statusCode; + } + + /** + * 获取 HTTP 应答包体内容 + */ + public String getBody() { + return body; + } + + /** + * 获取 HTTP 应答 Header + */ + public Headers getHeaders() { + return headers; + } + + /** + * 获取 错误码 (错误应答中的 code 字段) + */ + public String getErrorCode() { + return errorCode; + } + + /** + * 获取 错误消息 (错误应答中的 message 字段) + */ + public String getErrorMessage() { + return errorMessage; + } + } +} \ No newline at end of file diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/WxV3Pay.java b/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/WxV3Pay.java index ff256ae..2ba770d 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/WxV3Pay.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/wxPay/utils/WxV3Pay.java @@ -1,29 +1,21 @@ package com.ruoyi.system.wxPay.utils; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.ruoyi.system.utils.wx.tools.WxUtils; import com.ruoyi.system.wxPay.model.WeixinPayProperties; import com.ruoyi.system.wxPay.model.WxCloseOrderModel; -import com.ruoyi.system.wxPay.model.WxPaymentInfoModel; import com.ruoyi.system.wxPay.model.WxPaymentRefundModel; import com.ruoyi.system.wxPay.resp.NotifyV3PayDecodeRespBody; import com.wechat.pay.contrib.apache.httpclient.WechatPayHttpClientBuilder; import com.wechat.pay.contrib.apache.httpclient.auth.PrivateKeySigner; import com.wechat.pay.contrib.apache.httpclient.auth.Verifier; -import com.wechat.pay.contrib.apache.httpclient.auth.WechatPay2Credentials; import com.wechat.pay.contrib.apache.httpclient.auth.WechatPay2Validator; -import com.wechat.pay.contrib.apache.httpclient.cert.CertificatesManager; import com.wechat.pay.contrib.apache.httpclient.util.PemUtil; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; import org.apache.http.impl.client.CloseableHttpClient; import javax.servlet.http.HttpServletRequest; -import java.nio.charset.StandardCharsets; import java.security.PrivateKey; import java.util.Map; @@ -62,13 +54,6 @@ checkWxConfig(); this.privateKey = PemUtil.loadPrivateKey(config.getV3().getPrivateKeyStream()); this.privateKeySigner = new PrivateKeySigner(config.getV3().getMchSerialNo(), privateKey); - // 获取证书管理器实例 - CertificatesManager certificatesManager = CertificatesManager.getInstance(); - // 向证书管理器增加需要自动更新平台证书的商户信息 - certificatesManager.putMerchant(config.getMchId(), new WechatPay2Credentials(config.getMchId(), - this.privateKeySigner), config.getV3().getApiKey().getBytes(StandardCharsets.UTF_8)); - // 从证书管理器中获取verifier - this.verifier = certificatesManager.getVerifier(config.getMchId()); this.validator = new WechatPay2Validator(verifier); this.builder = WechatPayHttpClientBuilder.create() .withMerchant(config.getMchId(), config.getV3().getMchSerialNo(), this.privateKey) @@ -122,37 +107,76 @@ * @author xiaochen * @date 2022-03-22 12:47 */ - public Map<String, Object> jsApi(String tradeNo, Integer amount, String openid, String description) { - WxPaymentInfoModel requestBody = WxPaymentInfoModel.builder() - .mchid(this.config.getMchId()) - .appid(this.config.getAppId()) - .description(description) - .out_trade_no(tradeNo) -// .attach("") - .amount(WxPaymentInfoModel.Amount.builder().total(amount).build()) - .payer(WxPaymentInfoModel.Payer.builder().openid(openid).build()) - // 分不分账 -// .settle_info(WxPaymentInfoModel.SettleInfo.builder().profit_sharing(true).build()) - .build(); - // 封装基础数据 - String reqBody = buildBaseParam(requestBody - , this.config.getV3().getNotifyPayUrl()); - //请求URL - HttpEntityEnclosingRequestBase httpPost = requestPost( - "/v3/pay/transactions/jsapi" - , this.config.getHttpReadTimeoutMs() - , this.config.getHttpConnectTimeoutMs() - , reqBody); - String repBody = result(httpClient, httpPost); - ObjectMapper om = new ObjectMapper(); + public Map<String, Object> jsApi(String tradeNo, Long amount, String openid, String description,String attach) { + JsapiPrepay client = new JsapiPrepay( + this.config.getMchId(), // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756 + this.config.getV3().getMchSerialNo(), // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053 + this.config.getV3().getPrivateKeyPath(), // 商户API证书私钥文件路径,本地文件路径 + this.config.getV3().getPublicKeyId(), // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816 + this.config.getV3().getPublicKeyPath() // 微信支付公钥文件路径,本地文件路径 + ); + JsapiPrepay.DirectAPIv3JsapiPrepayRequest request = new JsapiPrepay.DirectAPIv3JsapiPrepayRequest(); + request.appid = this.config.getAppId(); + request.mchid = this.config.getMchId(); + request.description = description; + request.outTradeNo = tradeNo; +// request.timeExpire = "2018-06-08T10:34:56+08:00"; + request.attach = attach; + request.notifyUrl = this.config.getCallBackUrl(); + request.goodsTag = "WXG"; + request.supportFapiao = false; + request.amount = new JsapiPrepay.CommonAmountInfo(); + request.amount.total = amount; + request.amount.currency = "CNY"; + request.payer = new JsapiPrepay.JsapiReqPayerInfo(); + request.payer.openid = openid; try { - JsonNode rootNode = om.readTree(repBody); - String prepayId = rootNode.path("prepay_id").asText(); - return wxTuneUp(this.privateKeySigner, requestBody.getAppid(), prepayId); - } catch (JsonProcessingException e) { + JsapiPrepay.DirectAPIv3JsapiPrepayResponse response = client.run(request); + return wxTuneUp(this.privateKeySigner, this.config.getAppId(), response.prepayId); + } catch (WXPayUtility.ApiException e) { throw new RuntimeException("获取支付数据错误!"); } } +// /** +// * jsApi下单 +// * +// * @param tradeNo 订单号 +// * @param amount 金额 分 +// * @param openid openid +// * @param description 订单描述 +// * @return java.util.Map<java.lang.String, java.lang.Object> +// * @author xiaochen +// * @date 2022-03-22 12:47 +// */ +// public Map<String, Object> jsApi(String tradeNo, Integer amount, String openid, String description) { +// WxPaymentInfoModel requestBody = WxPaymentInfoModel.builder() +// .mchid(this.config.getMchId()) +// .appid(this.config.getAppId()) +// .description(description) +// .out_trade_no(tradeNo) +//// .attach("") +// .amount(WxPaymentInfoModel.Amount.builder().total(amount).build()) +// .payer(WxPaymentInfoModel.Payer.builder().openid(openid).build()) +// .build(); +// // 封装基础数据 +// String reqBody = buildBaseParam(requestBody +// , this.config.getV3().getNotifyPayUrl()); +// //请求URL +// HttpEntityEnclosingRequestBase httpPost = requestPost( +// "/v3/pay/transactions/jsapi" +// , this.config.getHttpReadTimeoutMs() +// , this.config.getHttpConnectTimeoutMs() +// , reqBody); +// String repBody = result(httpClient, httpPost); +// ObjectMapper om = new ObjectMapper(); +// try { +// JsonNode rootNode = om.readTree(repBody); +// String prepayId = rootNode.path("prepay_id").asText(); +// return wxTuneUp(this.privateKeySigner, requestBody.getAppid(), prepayId); +// } catch (JsonProcessingException e) { +// throw new RuntimeException("获取支付数据错误!"); +// } +// } /** -- Gitblit v1.7.1