diff --git a/api-third/pom.xml b/api-third/pom.xml index 8c06203..4fecb8c 100644 --- a/api-third/pom.xml +++ b/api-third/pom.xml @@ -22,6 +22,5 @@ - diff --git a/api-third/src/main/java/com/heyu/api/jsapi/CommonAmountInfo.java b/api-third/src/main/java/com/heyu/api/jsapi/CommonAmountInfo.java new file mode 100644 index 0000000..40feaba --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/CommonAmountInfo.java @@ -0,0 +1,11 @@ +package com.heyu.api.jsapi; + +import com.google.gson.annotations.SerializedName; + +public class CommonAmountInfo { + @SerializedName("total") + public Long total; + + @SerializedName("currency") + public String currency; +} \ No newline at end of file diff --git a/api-third/src/main/java/com/heyu/api/jsapi/CommonSceneInfo.java b/api-third/src/main/java/com/heyu/api/jsapi/CommonSceneInfo.java new file mode 100644 index 0000000..a756919 --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/CommonSceneInfo.java @@ -0,0 +1,16 @@ +package com.heyu.api.jsapi; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +@Data +public class CommonSceneInfo { + @SerializedName("payer_client_ip") + public String payerClientIp; + + @SerializedName("device_id") + public String deviceId; + + @SerializedName("store_info") + public StoreInfo storeInfo; +} diff --git a/api-third/src/main/java/com/heyu/api/jsapi/CouponInfo.java b/api-third/src/main/java/com/heyu/api/jsapi/CouponInfo.java new file mode 100644 index 0000000..e52ce58 --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/CouponInfo.java @@ -0,0 +1,19 @@ +package com.heyu.api.jsapi; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +import java.util.List; + +@Data +public class CouponInfo { + @SerializedName("cost_price") + public Long costPrice; + + @SerializedName("invoice_id") + public String invoiceId; + + @SerializedName("goods_detail") + public List goodsDetail; + +} \ No newline at end of file diff --git a/api-third/src/main/java/com/heyu/api/jsapi/DirectAPIv3JsapiPrepayRequest.java b/api-third/src/main/java/com/heyu/api/jsapi/DirectAPIv3JsapiPrepayRequest.java new file mode 100644 index 0000000..ee2dbf3 --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/DirectAPIv3JsapiPrepayRequest.java @@ -0,0 +1,49 @@ +package com.heyu.api.jsapi; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +@Data +public 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; + + @SerializedName("detail") + public CouponInfo detail; + + @SerializedName("scene_info") + public CommonSceneInfo sceneInfo; + + @SerializedName("settle_info") + public SettleInfo settleInfo; +} \ No newline at end of file diff --git a/api-third/src/main/java/com/heyu/api/jsapi/DirectAPIv3JsapiPrepayResponse.java b/api-third/src/main/java/com/heyu/api/jsapi/DirectAPIv3JsapiPrepayResponse.java new file mode 100644 index 0000000..d833966 --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/DirectAPIv3JsapiPrepayResponse.java @@ -0,0 +1,10 @@ +package com.heyu.api.jsapi; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +@Data +public class DirectAPIv3JsapiPrepayResponse { + @SerializedName("prepay_id") + public String prepayId; +} \ No newline at end of file diff --git a/api-third/src/main/java/com/heyu/api/jsapi/GoodsDetail.java b/api-third/src/main/java/com/heyu/api/jsapi/GoodsDetail.java new file mode 100644 index 0000000..134764d --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/GoodsDetail.java @@ -0,0 +1,22 @@ +package com.heyu.api.jsapi; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +@Data +public class GoodsDetail { + @SerializedName("merchant_goods_id") + public String merchantGoodsId; + + @SerializedName("wechatpay_goods_id") + public String wechatpayGoodsId; + + @SerializedName("goods_name") + public String goodsName; + + @SerializedName("quantity") + public Long quantity; + + @SerializedName("unit_price") + public Long unitPrice; + } diff --git a/api-third/src/main/java/com/heyu/api/jsapi/JsapiPrepay.java b/api-third/src/main/java/com/heyu/api/jsapi/JsapiPrepay.java new file mode 100644 index 0000000..2f06615 --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/JsapiPrepay.java @@ -0,0 +1,145 @@ +package com.heyu.api.jsapi; + +// 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831 + +import com.alibaba.fastjson.JSON; +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.ArrayList; + + +/** + * JSAPI下单 + */ +@Slf4j +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 static void main(String[] args) { + // TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756 + JsapiPrepay client = new JsapiPrepay( + "1731491745", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756 + "793D48CCEB62C6B227E0A4F46AD90279B149A7BE", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053 + "/Users/quyixiao/Desktop/weixincert/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径 + "PUB_KEY_ID_0117314917452025110400382304001401", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816 + "/Users/quyixiao/Desktop/weixincert/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径 + ); + + DirectAPIv3JsapiPrepayRequest request = new DirectAPIv3JsapiPrepayRequest(); + + request.appid = "wx75fa59c097bd3dfd"; + request.mchid = "1731491745"; + request.description = "Image形象店-深圳腾大-QQ公仔"; + request.outTradeNo = "121775250"; + + request.timeExpire = WXPayUtility.generateExpireTime(); // 2025-11-05T21:02:16+08:00 + request.attach = "自定义数据说明"; + request.notifyUrl = "https://api.1024api.com/api-interface/app/weixin/payNotify"; + request.goodsTag = "WXG"; + request.supportFapiao = false; + request.amount = new CommonAmountInfo(); + request.amount.total = 5L; + request.amount.currency = "CNY"; + request.payer = new JsapiReqPayerInfo(); + + request.payer.openid = "o6t1512tT-JuBeT6rIu6RhFGf3BQ"; + + request.detail = new CouponInfo(); + request.detail.costPrice = 608800L; + request.detail.invoiceId = "微信123"; + request.detail.goodsDetail = new ArrayList<>(); + { + GoodsDetail goodsDetailItem = new GoodsDetail(); + goodsDetailItem.merchantGoodsId = "1246464644"; + goodsDetailItem.wechatpayGoodsId = "1001"; + goodsDetailItem.goodsName = "iPhoneX 256G"; + goodsDetailItem.quantity = 1L; + goodsDetailItem.unitPrice = 528800L; + request.detail.goodsDetail.add(goodsDetailItem); + } + ; + request.sceneInfo = new CommonSceneInfo(); + request.sceneInfo.payerClientIp = "14.23.150.211"; + request.sceneInfo.deviceId = "013467007045764"; + request.sceneInfo.storeInfo = new StoreInfo(); + request.sceneInfo.storeInfo.id = "0001"; + request.sceneInfo.storeInfo.name = "腾讯大厦分店"; + request.sceneInfo.storeInfo.areaCode = "440305"; + request.sceneInfo.storeInfo.address = "广东省深圳市南山区科技中一道10000号"; + request.settleInfo = new SettleInfo(); + request.settleInfo.profitSharing = false; + try { + DirectAPIv3JsapiPrepayResponse response = client.rePay(request); + System.out.println(JSON.toJSONString(response)); + // TODO: 请求成功,继续业务逻辑 + System.out.println(response); + } catch (Exception e) { + // TODO: 请求失败,根据状态码执行不同的逻辑 + e.printStackTrace(); + } + } + + public DirectAPIv3JsapiPrepayResponse rePay(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); + log.info("JsapiPrepay respBody:{}", respBody); + if (respBody != null) { + } + 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); + } + } +} diff --git a/api-third/src/main/java/com/heyu/api/jsapi/JsapiReqPayerInfo.java b/api-third/src/main/java/com/heyu/api/jsapi/JsapiReqPayerInfo.java new file mode 100644 index 0000000..aadde5d --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/JsapiReqPayerInfo.java @@ -0,0 +1,8 @@ +package com.heyu.api.jsapi; + +import com.google.gson.annotations.SerializedName; + +public class JsapiReqPayerInfo { + @SerializedName("openid") + public String openid; +} \ No newline at end of file diff --git a/api-third/src/main/java/com/heyu/api/jsapi/SettleInfo.java b/api-third/src/main/java/com/heyu/api/jsapi/SettleInfo.java new file mode 100644 index 0000000..57ee4b5 --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/SettleInfo.java @@ -0,0 +1,8 @@ +package com.heyu.api.jsapi; + +import com.google.gson.annotations.SerializedName; + +public class SettleInfo { + @SerializedName("profit_sharing") + public Boolean profitSharing; +} \ No newline at end of file diff --git a/api-third/src/main/java/com/heyu/api/jsapi/StoreInfo.java b/api-third/src/main/java/com/heyu/api/jsapi/StoreInfo.java new file mode 100644 index 0000000..2f09c56 --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/StoreInfo.java @@ -0,0 +1,17 @@ +package com.heyu.api.jsapi; + +import com.google.gson.annotations.SerializedName; + +public class StoreInfo { + @SerializedName("id") + public String id; + + @SerializedName("name") + public String name; + + @SerializedName("area_code") + public String areaCode; + + @SerializedName("address") + public String address; +} \ No newline at end of file diff --git a/api-third/src/main/java/com/heyu/api/jsapi/WXPayUtility.java b/api-third/src/main/java/com/heyu/api/jsapi/WXPayUtility.java new file mode 100644 index 0000000..a963024 --- /dev/null +++ b/api-third/src/main/java/com/heyu/api/jsapi/WXPayUtility.java @@ -0,0 +1,894 @@ +package com.heyu.api.jsapi; + +import com.google.gson.ExclusionStrategy; +import com.google.gson.FieldAttributes; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.Map.Entry; + +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 javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +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.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.SignatureException; +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.security.MessageDigest; +import java.io.InputStream; + +import org.bouncycastle.crypto.digests.SM3Digest; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import java.security.Security; + +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); + } + } + + public static String aesAeadDecrypt(byte[] key, byte[] associatedData, byte[] nonce, + byte[] ciphertext) { + final String transformation = "AES/GCM/NoPadding"; + final String algorithm = "AES"; + final int tagLengthBit = 128; + + try { + Cipher cipher = Cipher.getInstance(transformation); + cipher.init( + Cipher.DECRYPT_MODE, + new SecretKeySpec(key, algorithm), + new GCMParameterSpec(tagLengthBit, nonce)); + if (associatedData != null) { + cipher.updateAAD(associatedData); + } + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (InvalidKeyException + | InvalidAlgorithmParameterException + | BadPaddingException + | IllegalBlockSizeException + | NoSuchAlgorithmException + | NoSuchPaddingException e) { + throw new IllegalArgumentException(String.format("AesAeadDecrypt with %s Failed", + transformation), 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); + } + + /** + * 计算输入流的哈希值 + * + * @param inputStream 输入流 + * @param algorithm 哈希算法名称,如 "SHA-256", "SHA-1" + * @return 哈希值的十六进制字符串 + */ + private static String calculateHash(InputStream inputStream, String algorithm) { + try { + MessageDigest digest = MessageDigest.getInstance(algorithm); + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + digest.update(buffer, 0, bytesRead); + } + byte[] hashBytes = digest.digest(); + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException(algorithm + " algorithm not available", e); + } catch (IOException e) { + throw new RuntimeException("Error reading from input stream", e); + } + } + + /** + * 计算输入流的 SHA256 哈希值 + * + * @param inputStream 输入流 + * @return SHA256 哈希值的十六进制字符串 + */ + public static String sha256(InputStream inputStream) { + return calculateHash(inputStream, "SHA-256"); + } + + /** + * 计算输入流的 SHA1 哈希值 + * + * @param inputStream 输入流 + * @return SHA1 哈希值的十六进制字符串 + */ + public static String sha1(InputStream inputStream) { + return calculateHash(inputStream, "SHA-1"); + } + + /** + * 计算输入流的 SM3 哈希值 + * + * @param inputStream 输入流 + * @return SM3 哈希值的十六进制字符串 + */ + public static String sm3(InputStream inputStream) { + // 确保Bouncy Castle Provider已注册 + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + + try { + SM3Digest digest = new SM3Digest(); + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + digest.update(buffer, 0, bytesRead); + } + byte[] hashBytes = new byte[digest.getDigestSize()]; + digest.doFinal(hashBytes, 0); + + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (IOException e) { + throw new RuntimeException("Error reading from input stream", e); + } + } + + /** + * 对参数进行 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 ""; + } + + StringBuilder result = new StringBuilder(); + for (Entry entry : params.entrySet()) { + if (entry.getValue() == null) { + continue; + } + + String key = entry.getKey(); + Object value = entry.getValue(); + if (value instanceof List) { + List list = (List) entry.getValue(); + for (Object temp : list) { + appendParam(result, key, temp); + } + } else { + appendParam(result, key, value); + } + } + return result.toString(); + } + + /** + * 将键值对 放入返回结果 + * + * @param result 返回的query string + * @param key 属性 + * @param value 属性值 + */ + private static void appendParam(StringBuilder result, String key, Object value) { + if (result.length() > 0) { + result.append("&"); + } + + String valueString; + // 如果是基本类型、字符串或枚举,直接转换;如果是对象,序列化为JSON + if (value instanceof String || value instanceof Number || + value instanceof Boolean || value instanceof Enum) { + valueString = value.toString(); + } else { + valueString = toJson(value); + } + + result.append(key) + .append("=") + .append(urlEncode(valueString)); + } + + + + /** + * 生成订单过期时间 + */ + public static String generateExpireTime() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX"); + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, 30); // 30分钟后过期 + return sdf.format(calendar.getTime()); + } + + /** + * 从应答中提取 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"); + String requestId = headers.get("Request-ID"); + try { + Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp)); + // 拒绝过期请求 + if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) { + throw new IllegalArgumentException( + String.format("Validate response failed, timestamp[%s] is expired, request-id[%s]", + timestamp, requestId)); + } + } catch (DateTimeException | NumberFormatException e) { + throw new IllegalArgumentException( + String.format("Validate response failed, timestamp[%s] is invalid, request-id[%s]", + timestamp, requestId)); + } + String serialNumber = headers.get("Wechatpay-Serial"); + if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) { + throw new IllegalArgumentException( + String.format("Validate response failed, Invalid Wechatpay-Serial, Local: %s, Remote: " + + "%s", wechatpayPublicKeyId, serialNumber)); + } + + String signature = headers.get("Wechatpay-Signature"); + String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"), + body == null ? "" : body); + + 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)); + } + } + + /** + * 根据微信支付APIv3通知验签规则对通知签名进行验证,验证不通过时抛出异常 + * + * @param wechatpayPublicKeyId 微信支付公钥ID + * @param wechatpayPublicKey 微信支付公钥对象 + * @param headers 微信支付通知 Header 列表 + * @param body 微信支付通知 Body + */ + public static void validateNotification(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 notification failed, timestamp[%s] is expired", timestamp)); + } + } catch (DateTimeException | NumberFormatException e) { + throw new IllegalArgumentException( + String.format("Validate notification failed, timestamp[%s] is invalid", timestamp)); + } + String serialNumber = headers.get("Wechatpay-Serial"); + if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) { + throw new IllegalArgumentException( + String.format("Validate notification failed, Invalid Wechatpay-Serial, Local: %s, " + + "Remote: %s", + wechatpayPublicKeyId, + serialNumber)); + } + + String signature = headers.get("Wechatpay-Signature"); + String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"), + body == null ? "" : body); + + boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey); + if (!success) { + throw new IllegalArgumentException( + String.format("Validate notification failed, WechatPay signature is incorrect.\n" + + "responseHeader[%s]\tresponseBody[%.1024s]", + headers, body)); + } + } + + /** + * 对微信支付通知进行签名验证、解析,同时将业务数据解密。验签名失败、解析失败、解密失败时抛出异常 + * + * @param apiv3Key 商户的 APIv3 Key + * @param wechatpayPublicKeyId 微信支付公钥ID + * @param wechatpayPublicKey 微信支付公钥对象 + * @param headers 微信支付请求 Header 列表 + * @param body 微信支付请求 Body + * @return 解析后的通知内容,解密后的业务数据可以使用 Notification.getPlaintext() 访问 + */ + public static Notification parseNotification(String apiv3Key, String wechatpayPublicKeyId, + PublicKey wechatpayPublicKey, Headers headers, + String body) { + validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); + Notification notification = gson.fromJson(body, Notification.class); + notification.decrypt(apiv3Key); + return notification; + } + + /** + * 微信支付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 = gson.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; + } + } + + public static class Notification { + @SerializedName("id") + private String id; + @SerializedName("create_time") + private String createTime; + @SerializedName("event_type") + private String eventType; + @SerializedName("resource_type") + private String resourceType; + @SerializedName("summary") + private String summary; + @SerializedName("resource") + private Resource resource; + private String plaintext; + + public String getId() { + return id; + } + + public String getCreateTime() { + return createTime; + } + + public String getEventType() { + return eventType; + } + + public String getResourceType() { + return resourceType; + } + + public String getSummary() { + return summary; + } + + public Resource getResource() { + return resource; + } + + /** + * 获取解密后的业务数据(JSON字符串,需要自行解析) + */ + public String getPlaintext() { + return plaintext; + } + + private void validate() { + if (resource == null) { + throw new IllegalArgumentException("Missing required field `resource` in notification"); + } + resource.validate(); + } + + /** + * 使用 APIv3Key 对通知中的业务数据解密,解密结果可以通过 getPlainText 访问。 + * 外部拿到的 Notification 一定是解密过的,因此本方法没有设置为 public + * + * @param apiv3Key 商户APIv3 Key + */ + private void decrypt(String apiv3Key) { + validate(); + + plaintext = aesAeadDecrypt( + apiv3Key.getBytes(StandardCharsets.UTF_8), + resource.associatedData.getBytes(StandardCharsets.UTF_8), + resource.nonce.getBytes(StandardCharsets.UTF_8), + Base64.getDecoder().decode(resource.ciphertext) + ); + } + + public static class Resource { + @SerializedName("algorithm") + private String algorithm; + + @SerializedName("ciphertext") + private String ciphertext; + + @SerializedName("associated_data") + private String associatedData; + + @SerializedName("nonce") + private String nonce; + + @SerializedName("original_type") + private String originalType; + + public String getAlgorithm() { + return algorithm; + } + + public String getCiphertext() { + return ciphertext; + } + + public String getAssociatedData() { + return associatedData; + } + + public String getNonce() { + return nonce; + } + + public String getOriginalType() { + return originalType; + } + + private void validate() { + if (algorithm == null || algorithm.isEmpty()) { + throw new IllegalArgumentException("Missing required field `algorithm` in Notification" + + ".Resource"); + } + if (!Objects.equals(algorithm, "AEAD_AES_256_GCM")) { + throw new IllegalArgumentException(String.format("Unsupported `algorithm`[%s] in " + + "Notification.Resource", algorithm)); + } + + if (ciphertext == null || ciphertext.isEmpty()) { + throw new IllegalArgumentException("Missing required field `ciphertext` in Notification" + + ".Resource"); + } + + if (associatedData == null || associatedData.isEmpty()) { + throw new IllegalArgumentException("Missing required field `associatedData` in " + + "Notification.Resource"); + } + + if (nonce == null || nonce.isEmpty()) { + throw new IllegalArgumentException("Missing required field `nonce` in Notification" + + ".Resource"); + } + + if (originalType == null || originalType.isEmpty()) { + throw new IllegalArgumentException("Missing required field `originalType` in " + + "Notification.Resource"); + } + } + } + } + + /** + * 根据文件名获取对应的Content-Type + * + * @param fileName 文件名 + * @return Content-Type字符串 + */ + public static String getContentTypeByFileName(String fileName) { + if (fileName == null || fileName.isEmpty()) { + return "application/octet-stream"; + } + + // 获取文件扩展名 + String extension = ""; + int lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) { + extension = fileName.substring(lastDotIndex + 1).toLowerCase(); + } + + // 常见文件类型映射 + Map contentTypeMap = new HashMap<>(); + // 图片类型 + contentTypeMap.put("png", "image/png"); + contentTypeMap.put("jpg", "image/jpeg"); + contentTypeMap.put("jpeg", "image/jpeg"); + contentTypeMap.put("gif", "image/gif"); + contentTypeMap.put("bmp", "image/bmp"); + contentTypeMap.put("webp", "image/webp"); + contentTypeMap.put("svg", "image/svg+xml"); + contentTypeMap.put("ico", "image/x-icon"); + + // 文档类型 + contentTypeMap.put("pdf", "application/pdf"); + contentTypeMap.put("doc", "application/msword"); + contentTypeMap.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + contentTypeMap.put("xls", "application/vnd.ms-excel"); + contentTypeMap.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + contentTypeMap.put("ppt", "application/vnd.ms-powerpoint"); + contentTypeMap.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + + // 文本类型 + contentTypeMap.put("txt", "text/plain"); + contentTypeMap.put("html", "text/html"); + contentTypeMap.put("css", "text/css"); + contentTypeMap.put("js", "application/javascript"); + contentTypeMap.put("json", "application/json"); + contentTypeMap.put("xml", "application/xml"); + contentTypeMap.put("csv", "text/csv"); + + // 音视频类型 + contentTypeMap.put("mp3", "audio/mpeg"); + contentTypeMap.put("wav", "audio/wav"); + contentTypeMap.put("mp4", "video/mp4"); + contentTypeMap.put("avi", "video/x-msvideo"); + contentTypeMap.put("mov", "video/quicktime"); + + // 压缩文件类型 + contentTypeMap.put("zip", "application/zip"); + contentTypeMap.put("rar", "application/x-rar-compressed"); + contentTypeMap.put("7z", "application/x-7z-compressed"); + + + return contentTypeMap.getOrDefault(extension, "application/octet-stream"); + } +} \ No newline at end of file diff --git a/api-web/api-interface/src/main/resources/1731491745_20251104_cert/wxp_pub.pem b/api-web/api-interface/src/main/resources/1731491745_20251104_cert/wxp_pub.pem new file mode 100644 index 0000000..1356a26 --- /dev/null +++ b/api-web/api-interface/src/main/resources/1731491745_20251104_cert/wxp_pub.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx/QqdSnkS8wd7xFH0LHV +pZTFVI+eWL383eAP7RaxHfRwXNDfZYrdi/Bt8Enko+1Wkr2FGY2kS4Hol5NCvuZ7 +UY28/L4r95dBEgXtflkrxeVbiNbWHtiIdNXZSgkrpUkZEFoh7ks5Ou3pAlpG+94/ +Nnc//D/yckM7AuD85G1foZMSO5niyVeFAed6z7CeBMAhRVdnOIDUqsI/NaHruT4f +vWNzPnn5SQKoKum00vRHckxftUqMkZ1D/YxHiAycEc9H0hCgLW7ZM6UV88Loa6SP +vhM0fmXIuk6p277ldlxFl6Bcxd5jOcCTjdpWaB3CeSXnSIBl6KsZNcV3vPC3xey1 +WwIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file