From 52001001865df8262d5ddc177e50cee3ed687b1e Mon Sep 17 00:00:00 2001 From: Aug <17674666882@163.com> Date: Fri, 4 Jul 2025 10:27:12 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E6=8F=90=E7=8E=B0=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/AppletMoneyLogServiceImpl.java | 227 ++++++++++-- .../org/jeecg/api/transfer/TransferToUser.java | 148 ++++++++ .../java/org/jeecg/api/transfer/WXPayUtility.java | 381 +++++++++++++++++++++ module-system/src/main/resources/apiclient_key.pem | 52 +-- .../src/main/resources/application-dev.yml | 18 +- module-system/src/main/resources/pub_key.pem | 9 + 6 files changed, 774 insertions(+), 61 deletions(-) create mode 100644 module-common/src/main/java/org/jeecg/api/transfer/TransferToUser.java create mode 100644 module-common/src/main/java/org/jeecg/api/transfer/WXPayUtility.java create mode 100644 module-system/src/main/resources/pub_key.pem diff --git a/module-common/src/main/java/org/jeecg/api/service/impl/AppletMoneyLogServiceImpl.java b/module-common/src/main/java/org/jeecg/api/service/impl/AppletMoneyLogServiceImpl.java index 8bcf56a..c673258 100644 --- a/module-common/src/main/java/org/jeecg/api/service/impl/AppletMoneyLogServiceImpl.java +++ b/module-common/src/main/java/org/jeecg/api/service/impl/AppletMoneyLogServiceImpl.java @@ -1,10 +1,14 @@ package org.jeecg.api.service.impl; import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.map.HashedMap; import org.apache.commons.lang3.StringUtils; import org.jeecg.api.bean.PageBean; import org.jeecg.api.service.AppletMoneyLogService; +import org.jeecg.api.transfer.TransferToUser; import org.jeecg.common.api.vo.Result; import org.jeecg.config.shiro.ShiroRealm; import org.jeecg.modules.commonBook.entity.CommonBook; @@ -12,14 +16,38 @@ import org.jeecg.modules.commonMoneyLog.entity.CommonMoneyLog; import org.jeecg.modules.commonMoneyLog.service.ICommonMoneyLogService; import org.jeecg.modules.hanHaiMember.entity.HanHaiMember; import org.jeecg.modules.hanHaiMember.service.IHanHaiMemberService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Map; +@Slf4j @Service public class AppletMoneyLogServiceImpl implements AppletMoneyLogService { + /*************************************************************************************/ + //微信小程序的 AppID + @Value("${wechat.mpAppId}") + private String appid; + //商户号 + @Value("${wechat.merchantId}") + private String mchid; + //商户API私钥路径 + @Value("${wechat.privateKeyPath}") + private String privateKeyFilePath; + //商户API公钥路径 + @Value("${wechat.publicKeyPath}") + private String wechatPayPublicKeyFilePath; + //商户API公钥ID + @Value("${wechat.publicKeyId}") + private String wechatPayPublicKeyId; + //商户证书序列号 + @Value("${wechat.merchantSerialNumber}") + private String certiticateSerialNo; + //权限 @Resource private ShiroRealm shiroRealm; @@ -27,6 +55,7 @@ public class AppletMoneyLogServiceImpl implements AppletMoneyLogService { private ICommonMoneyLogService commonMoneyLogService; @Resource private IHanHaiMemberService hanHaiMemberService; + /*************************************************************************************/ @@ -45,40 +74,182 @@ public class AppletMoneyLogServiceImpl implements AppletMoneyLogService { } @Override - public Result withdraw(String token, CommonMoneyLog log) { + public Result withdraw(String token, CommonMoneyLog commonMoneyLog) { + //权限验证 HanHaiMember member = shiroRealm.checkUserTokenIsEffectHanHaiOpenId(token); + //HanHaiMember member = hanHaiMemberService.getById("1928663563891728385"); - // 检查用户余额是否足够 - if (member.getMoney() == null || member.getMoney().compareTo(log.getMoney()) < 0) { - return Result.error("余额不足,无法提现"); - } + //提现结果 + String massage = "提现申请失败"; + + try{ + //0、基础约束 + BigDecimal balance = member.getMoney();//用户余额 + //金额不能为空 + if(null == commonMoneyLog.getMoney()){ + log.info("金额为空,请填写大于0的整数金额"); + return Result.error("金额小于0,请填写大于0的整数金额"); + } + if(commonMoneyLog.getMoney().compareTo(balance)>0){ + //提现金额大于推广佣金 + log.info("推广佣金不足,当前推广佣金:{}", balance); + return Result.error("推广佣金不足"); + } + //提现金额要为整数 + if(commonMoneyLog.getMoney().scale()>0){ + log.info("请填写大于0的整数金额,当前输入金额:{}", commonMoneyLog.getMoney()); + return Result.error("请填写大于0的整数金额"); + } + //提现金额大于0的整数 + if(commonMoneyLog.getMoney().compareTo(new BigDecimal(0))<=0){ + log.info("请填写大于0的整数金额,当前输入金额:{}", commonMoneyLog.getMoney()); + return Result.error("请填写大于0的整数金额"); + } + if(org.apache.commons.lang.StringUtils.isEmpty(commonMoneyLog.getUserName())){ + log.info("用户姓名未填写"); + return Result.error("用户姓名未填写"); + } + + + //1.微信提现基础参数 + Map map = getMap(); + //变化的用户信息参数 + map.put("openid", member.getAppletOpenid());//用户openid(小程序) + map.put("userName", commonMoneyLog.getUserName());//用户真实姓名 + map.put("transferAmount", commonMoneyLog.getMoney());//提现金额, 单位为“分” + String idStr = "H" + IdWorker.getIdStr(); + map.put("outBillNo", idStr);//商户单号 + + TransferToUser client = new TransferToUser( + map.get("mchid").toString(), // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756 + map.get("certiticateSerialNo").toString(), // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053 + map.get("privateKeyFilePath").toString(), // 商户API证书私钥文件路径,本地文件路径 + map.get("wechatPayPublicKeyId").toString(), // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816 + map.get("wechatPayPublicKeyFilePath").toString() // 微信支付公钥文件路径,本地文件路径 + ); + + //2、场景信息 + TransferToUser.TransferToUserRequest request = new TransferToUser.TransferToUserRequest(); + request.appid = map.get("appid").toString(); + request.outBillNo = map.get("outBillNo").toString(); + request.transferSceneId = map.get("transferSceneId").toString(); + request.openid = map.get("openid").toString(); + request.userName = client.encrypt(map.get("userName").toString()); + request.transferAmount = commonMoneyLog.getMoney().longValue()*100;//单位为分 + request.transferRemark = map.get("transferRemark").toString(); + request.notifyUrl = map.get("notifyUrl").toString(); + request.userRecvPerception = map.get("userRecvPerception").toString(); + request.transferSceneReportInfos = new ArrayList<>(); + { + TransferToUser.TransferSceneReportInfo item0 = new TransferToUser.TransferSceneReportInfo(); + item0.infoType = map.get("infoType1").toString(); + item0.infoContent = map.get("infoContent1").toString(); + request.transferSceneReportInfos.add(item0); + } + + //3、执行提现 + TransferToUser.TransferToUserResponse response = client.run(request, map); + log.info("提现发起成功,outBillNo:"+response.outBillNo + ",transferBillNo:" +response.transferBillNo + ",state:" +response.state+ ",packageInfo:" + response.packageInfo); + switch (response.state){ + case ACCEPTED: + log.info("转账已受理"); + massage = "转账已受理"; + break; + case PROCESSING: + log.info("转账锁定资金中。如果一直停留在该状态,建议检查账户余额是否足够,如余额不足,可充值后再原单重试"); + massage = "转账锁定资金中。如果一直停留在该状态,建议检查账户余额是否足够,如余额不足,可充值后再原单重试"; + break; + case WAIT_USER_CONFIRM: + log.info("待收款用户确认,可拉起微信收款确认页面进行收款确认"); + massage = "待收款用户确认,可拉起微信收款确认页面进行收款确认"; + break; + case TRANSFERING: + log.info("转账中,可拉起微信收款确认页面再次重试确认收款"); + massage = "转账中,可拉起微信收款确认页面再次重试确认收款"; + break; + case SUCCESS: + log.info("转账成功"); + massage = "转账成功"; + break; + case FAIL: + log.info("转账失败"); + massage = "转账失败"; + break; + case CANCELING: + log.info("商户撤销请求受理成功,该笔转账正在撤销中"); + massage = "商户撤销请求受理成功,该笔转账正在撤销中"; + break; + case CANCELLED: + log.info("转账撤销完成"); + massage = "转账撤销完成"; + break; + } + + log.info("提现结果:" + massage); + + //4、业务处理 + // 设置提现记录信息 + commonMoneyLog.setTitle("提现记录"); + commonMoneyLog.setUserId(member.getId()); + commonMoneyLog.setType(1); // 支出类型 + commonMoneyLog.setState(0); // 待处理状态 + commonMoneyLog.setIsBrokerage("N"); // 不是佣金 + commonMoneyLog.setOldMoney(member.getMoney()); // 记录原始余额 + + // 更新用户余额 + BigDecimal newMoney = member.getMoney().subtract(commonMoneyLog.getMoney()); + member.setMoney(newMoney); + + // 更新累计提现金额 + if (member.getIntegerPrice() == null) { + member.setIntegerPrice(commonMoneyLog.getMoney()); + } else { + member.setIntegerPrice(member.getIntegerPrice().add(commonMoneyLog.getMoney())); + } + + // 保存用户信息和提现记录 + hanHaiMemberService.updateById(member); + commonMoneyLogService.save(commonMoneyLog); + + + //5、返回信息 + return Result.OK(massage, response); + }catch (Exception e){ + e.printStackTrace(); + log.info("提现失败:" + e.getMessage()); + return Result.error(e.getMessage()); - // 设置提现记录信息 - log.setTitle("提现记录"); - log.setUserId(member.getId()); - log.setType(1); // 支出类型 - log.setState(0); // 待处理状态 - log.setIsBrokerage("N"); // 不是佣金 - log.setOldMoney(member.getMoney()); // 记录原始余额 - - // 更新用户余额 - BigDecimal newMoney = member.getMoney().subtract(log.getMoney()); - member.setMoney(newMoney); - - // 更新累计提现金额 - if (member.getIntegerPrice() == null) { - member.setIntegerPrice(log.getMoney()); - } else { - member.setIntegerPrice(member.getIntegerPrice().add(log.getMoney())); } - - // 保存用户信息和提现记录 - hanHaiMemberService.updateById(member); - commonMoneyLogService.save(log); + } + + /** + * 微信提现基础参数 + * @return + */ + public Map getMap(){ + Map map = new HashedMap();//转账接口所需参数 + map.put("host", "https://api.mch.weixin.qq.com");//请求地址 + map.put("method", "POST");//请求类型 + map.put("path", "/v3/fund-app/mch-transfer/transfer-bills");//提现接口 + map.put("notifyUrl", "https://www.yurangongfang.com/massage-admin/massage/cash/cashoutNotify/");//回调接口 + + //微信商户参数 + map.put("appid", appid);//小程序appid + map.put("mchid", mchid);//商户号 + map.put("certiticateSerialNo", certiticateSerialNo);//商户序列号 + map.put("privateKeyFilePath", privateKeyFilePath);//商户私钥证书 + map.put("wechatPayPublicKeyId", wechatPayPublicKeyId);//商户公钥id + map.put("wechatPayPublicKeyFilePath", wechatPayPublicKeyFilePath);//商户公钥证书 + map.put("transferSceneId", "1010");//商户转账场景ID 1010-二手回收 + map.put("transferRemark", "二手衣物回收");//商户转账场景ID 1010-佣金报酬 + map.put("userRecvPerception", "");//商户转账场景ID 1010-佣金报酬 + + //转账场景报备信息,ransfer_scene_report_infos为数组类型参数。 + map.put("infoType1","回收商品名称"); + map.put("infoContent1","衣服"); - // TODO 后续对接微信转账 + return map; - return Result.OK("提现申请已提交"); } @Override diff --git a/module-common/src/main/java/org/jeecg/api/transfer/TransferToUser.java b/module-common/src/main/java/org/jeecg/api/transfer/TransferToUser.java new file mode 100644 index 0000000..f89908c --- /dev/null +++ b/module-common/src/main/java/org/jeecg/api/transfer/TransferToUser.java @@ -0,0 +1,148 @@ +package org.jeecg.api.transfer; + +import com.google.gson.annotations.SerializedName; +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.List; +import java.util.Map; + +/** + * 发起转账 + */ +@Slf4j +public class TransferToUser { + + private final String mchid; + private final String certificateSerialNo; + private final PrivateKey privateKey; + private final String wechatPayPublicKeyId; + private final PublicKey wechatPayPublicKey; + + public TransferToUser(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 TransferToUserResponse run(TransferToUserRequest request, Map map) { + String uri = map.get("path").toString(); + String host = map.get("host").toString(); + String method = map.get("method").toString(); + 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, TransferToUserResponse.class); + } else { + throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers()); + } + } catch (IOException e) { + throw new UncheckedIOException("Sending request to " + uri + " failed.", e); + } + } + + public String encrypt(String plainText) { + return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText); + } + + public static class TransferToUserResponse { + @SerializedName("out_bill_no") + public String outBillNo; + + @SerializedName("transfer_bill_no") + public String transferBillNo; + + @SerializedName("create_time") + public String createTime; + + @SerializedName("state") + public TransferBillStatus state; + + @SerializedName("package_info") + public String packageInfo; + } + + public enum TransferBillStatus { + @SerializedName("ACCEPTED") + ACCEPTED, + @SerializedName("PROCESSING") + PROCESSING, + @SerializedName("WAIT_USER_CONFIRM") + WAIT_USER_CONFIRM, + @SerializedName("TRANSFERING") + TRANSFERING, + @SerializedName("SUCCESS") + SUCCESS, + @SerializedName("FAIL") + FAIL, + @SerializedName("CANCELING") + CANCELING, + @SerializedName("CANCELLED") + CANCELLED + } + + public static class TransferSceneReportInfo { + @SerializedName("info_type") + public String infoType; + + @SerializedName("info_content") + public String infoContent; + } + + public static class TransferToUserRequest { + @SerializedName("appid") + public String appid; + + @SerializedName("out_bill_no") + public String outBillNo; + + @SerializedName("transfer_scene_id") + public String transferSceneId; + + @SerializedName("openid") + public String openid; + + @SerializedName("user_name") + public String userName; + + @SerializedName("transfer_amount") + public Long transferAmount; + + @SerializedName("transfer_remark") + public String transferRemark; + + @SerializedName("notify_url") + public String notifyUrl; + + @SerializedName("user_recv_perception") + public String userRecvPerception; + + @SerializedName("transfer_scene_report_infos") + public List transferSceneReportInfos; + } + +} diff --git a/module-common/src/main/java/org/jeecg/api/transfer/WXPayUtility.java b/module-common/src/main/java/org/jeecg/api/transfer/WXPayUtility.java new file mode 100644 index 0000000..aab084d --- /dev/null +++ b/module-common/src/main/java/org/jeecg/api/transfer/WXPayUtility.java @@ -0,0 +1,381 @@ +package org.jeecg.api.transfer; + +import com.google.gson.*; +import com.google.gson.annotations.Expose; +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 fromJson(String json, Class 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 params) { + if (params == null || params.isEmpty()) { + return ""; + } + + int index = 0; + StringBuilder result = new StringBuilder(); + for (Map.Entry 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 { + public final int statusCode; + public final String body; + public final Headers headers; + public ApiException(int statusCode, String body, Headers headers) { + super(String.format("微信支付API访问失败,StatusCode: %s, Body: %s", statusCode, body)); + this.statusCode = statusCode; + this.body = body; + this.headers = headers; + } + } +} diff --git a/module-system/src/main/resources/apiclient_key.pem b/module-system/src/main/resources/apiclient_key.pem index e8ae0f7..ca22482 100644 --- a/module-system/src/main/resources/apiclient_key.pem +++ b/module-system/src/main/resources/apiclient_key.pem @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDDlD79uHUwjr8r -hSlk7gyCvmWAOH9w1nvl6vjStN0UEbGkPYEtZhAKpykwK5uPV0CXHJI8a7Ilv+qr -C0Y1m+MQ63SZ8AHb+e5wd+iGkXSXX4T/y2jBHBGVxJkwPB7bN7nh7UYq4OZ1PB5x -KXvYwYgrTtmDKxMrwbaodJryLbi6/WzC4QcKLSQTwJ6O9ehHVrUWbBXizYKZX/QH -j9BQY73GtfNhj/ppVVh48dPkfYy20pGS5tOP6VDHslNQe1FHsULz2QKr9fqUcGKl -9ChnpbcUAkDtHzlI2NDYiVsidRJZjajJA4zpYyn3Sb9dl6VG/BsVgLTASPDoh8li -nXKksmuBAgMBAAECggEAf4/cee3qeY5RP9hthEgDXu9CEpxG+tjaHL7iJcQTgfh8 -bcwzyeGMyvX2VlXK83YMScM32jLAEgEX1RHYbDTNqAZ6mcDB5bEhBLggsEyEyApk -G9aW74UYLx/4bk54LbEuCx6QKn1fss1QaayN+3VXFDAsjHH24g5JzZuoSBbsKwDl -WVP3wHh87x4eiXnDtR9hBG5ul5AXRhuE24ka1cpYE0N3/7cm+D+lNfvXz/A427z9 -6+El2sBTutrfIzFnJjz2KUX1RqFHXqBWVwCxUPH0SSp6l59qy/kWOkIW1nlOjo8x -fPoLEd6y6x5YgaNSLG3ur4hhxp7h/2beVqwfmSSxJQKBgQDwuG8LTpB+xDKjmE3C -sX82n1yvE6I0UQo4mc/5LkS/PxsKAE9zo6yX96WsFThY8S43Ep6gg6SdMVGINHBx -vPudiWgIrhGMocJ+9PHapJQBtUAxem64gNVxSXFrfyphjO+GRtM4ji9+71fcUwFD -lS0T0wKm5MVCbSzbUWIdGgWM5wKBgQDP/kqqP/SalLD89dQa4jaJ5jOQJTiYq7lB -9BIsy23dQEv5mNXJS6pcOjayuE1iaiYdA0Ri0Rueu4xZ+hNaAdSHcH1Pgt/0qMI7 -+SuaHjPFxffNFtePkmHg6dS9JzXwQ/xub6ZOf+E/qUf1BTv6ja0o69ApWl0RFtAF -xL/Z6kIPVwKBgETsujbllvAFI26+ND2z7vXn6XTjzUTnk2Kjf+4cNmkAG7DgZ993 -lPqqWRCNvuWQoSf5t9vD9cVgkrTKNwwKDY2NA3HAzZuT0YnifsGY8BwRFsFUChHg -Kb1XRxd9gNgPr6Gl8+K0q5rP0ztttOXx98c+WvsIdAbSFc7yXYJxqfcvAoGAAorI -HNaVRcJle2IByqZTJlJS9QMPcwY+SGkUQ8nkuNyNUSqmCkTLez8W5g5Mm9RSTO56 -Sn7lyIXgTEU7MVFuaI1earddx168qQD9oG+YEGXABpit38pZOeeBuyIcjag3EJ56 -uODlPuLxxzPeLMzIfgSL0cWR96CAwGFMOvya/BcCgYBpOOie5LdFOLh0kCRWk0sg -1/pIJYr9hXmG+QEFSELZ56mWl0ujPfZ8g7bh211RZ5FWaddD3ba7nZR6u884bRwN -9Tg/BKz5rXd7FkKtaodxs1/ti1WLGdrC/mnCjIq0LfnVK6Zg5dbOSGdE2qQaVyPY -ZUrwYWW/VDgsAyeMuWWdEQ== +MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQCd78AKWRvl0n0b +BSKQl0lm59RW2grUHewY9CBnKiy6K+h7gZyORuglyeGplq9BTMLYiOvd5AIzoaOF +BM9JY4bp3XPCzquDCTOW6jEz9exn2Shp5pQD/qsZQXlC617ZVEQxW2Y7HRc9yP1x +w/mlbIJA+OwX4S15E7dC9uKQhQ95xZASzIkLdrUQkgAd72Dlq6n7mCcNwvUluLhM +7q0QDrfyIXlZuLVt+J39FlCUd1gR5yve5oQ4NsW407ZXriIo1sCdzhcdPEo+6Bkc +XV6gs4I9e/IEYeQgodi4t6aOBsqzujkyMgzvhV495h+O/GDI1xNmX/U5huXhmqgu +3e8DH+2VAgMBAAECggEBAJyDwum+YyI/lZGYZieqdaiQaGLl1ENfn+Ee1ZnHNuoM +p2j2CmMJK/h+965r0SRCYPnbvvt3gSMPtZAWMyJEiqfquVS+IURLcx/E2Jvow64q +i51fTyIiWAdAVl6WCLoRgz7yl/5PFwA41cnPb/Ca9v5ScQ49LiZF/qBTMDYOV0bR +jyUjkkWXQvLsccY5dwp6nEAU6eQXBxQlyHiwPJaYV8WVep2MexWVXqNg/S8hPPmi +3+Jg6FwWPbbF7482bl+bHxmoUjpVZLqcgpOxymNeY+nHlKT1t8M5rqNTjCVm2d/O +/O4gX1D5cDqPSEydVG08P4uzmCkS2Vc7M83AyKny/eECgYEAyzf1GFRH3vHhsMwu +oaM29p0ZHNRiJ2wcP0gBWxICosiM4Gt0GYroHht1cW1/xPfymxUgfy9PbTN9EMgZ +pplvXZnczht3IzaAb+etONCQEjYYjPaXcCtb29db5xAUHMKEtwtonuyPpmLXcNPu +DbMzJq0pXWkZO4rn3eN/yvgYTZkCgYEAxvT63M21II3Cd2VKddeYL0fl4d6G2xs5 +MfwFHnitS7Yoya9p5gyhrrGVtOajJL0pN2fe+bbc/ON/o4rJdt+VXhnbSH/y1xZ2 +xMbrOaehdbJCCOArH6ddSqI+WWEPN1Khy46P5C+BdCdKkahEd50e9gWoVaWF8Mat +4/yGJq5dxV0CgYEAj0Rs83D/lkqanSTlvyis6CILAUstw7MxGt/trMG+v/p5bwHy +QMuwJwGew4+54ygWjoTPezrkWQ1Sn5EinFRRsgB2LYwpatbSp5ICiipnV59xoXd7 +fiXjRdLHRNgOBdSQ8ecGI/yMrXXDgxS2IEx0xFCR9/vPQS4Fl2X6wyIzKAkCgYEA +suPteIHh/Z5ZH5/M3r52kPS1x2ydlv8YY1A2jhp1v2DPteDqeDYdXoVAlcLXKB6J +o87al2+LrjskNjmBQkhw9oaLO9oH0MvbCL3PHS0TqU8Zvv9I5xJGeOf+5cC2vMio +v+20hbkNThJSIzvILEDzaMTXggLttvv9uMQDH16i4+UCgYEAhrLdn9gA9AFuArYG +Qi82NLJ3QDaT2/RiJ2jhGywqK3Dx0n7fp1uQ21gzdV8d9aT8TL5xLd3eRhFpGRpd +J7uMwadmNIBjBGxWcfPWOcgynAIDvuRweB7HckUjZcNjGnYwkvPrA5+pWQkWi6n6 +Y2FtmLanHI/B3S+G851jkXfzzbg= -----END PRIVATE KEY----- diff --git a/module-system/src/main/resources/application-dev.yml b/module-system/src/main/resources/application-dev.yml index 9bc44bc..ea6ceed 100644 --- a/module-system/src/main/resources/application-dev.yml +++ b/module-system/src/main/resources/application-dev.yml @@ -330,15 +330,19 @@ third-app: client-secret: ?? agent-id: ?? - ##配置微信##爱简收旧衣按件回收小程序 wechat: - mpAppId: wx2bd656ae9704dbee - mpAppSecret: b07f82b16e9598bc23de604f49c57e23 - mchId: - mchKey: - keyPath: - notifyUrl: + mpAppId: wx2bd656ae9704dbee # 微信小程序appid + mpAppSecret: b07f82b16e9598bc23de604f49c57e23 # 微信小程序密钥 + merchantId: 1701841654 # 商户号 + privateKeyPath: module-system/src/main/resources/apiclient_key.pem #本地私钥路径 + publicKeyPath: module-system/src/main/resources/pub_key.pem #本地公钥路径 + # privateKeyPath: /data/app-test/hly/cerFile/apiclient_key.pem #线上私钥路径 + # publicKeyPath: /data/app-test/hly/cerFile/pub_key.pem #线上公钥路径 + publicKeyId: PUB_KEY_ID_0117018416542025022100395100001649 #公钥 + merchantSerialNumber: 525971050851A99F315A970D3055192779652E03 # 商户API证书序列号 + apiV3Key: # 商户APIV3密钥 + refundNotifyUrl: # 退款通知地址(正式环境) diff --git a/module-system/src/main/resources/pub_key.pem b/module-system/src/main/resources/pub_key.pem new file mode 100644 index 0000000..995510d --- /dev/null +++ b/module-system/src/main/resources/pub_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8qX7Ko/dx5/MDNj3wwzc +vqt5cSA85mVQH63vkQm/ffimHGixzwvcyq7QldyaLPLMzCG3jKsVpY7CKXyAANMT +WT4tAj4Kzi55ZkjJXWCH9I3zuLFFA27zrY0xd5PVbzxnNWaMq2S1Lkn+ul9y77Jb +VcI26MXqvFxkjT9Yxg/rEF2sh989/xM7tUpyJg8O8ZyMkmvjtdEmZxcr+SOkbHQz +qcDPIBWWElB5pocMHATvro9G8VHzCZONB7DyQq/w+ck58BU/4s6KGTZ6NIHjS/ex +C24tdJdMRxDcacUmjsH45m+QoG66gfUi8XuNodMl8Xkmbxq93xM03ophFjnVstH6 +1wIDAQAB +-----END PUBLIC KEY-----