diff --git a/ruoyi-admin/src/main/java/org/dromara/app/AppMemberController.java b/ruoyi-admin/src/main/java/org/dromara/app/AppMemberController.java index 2c74e3426..40af9e7cf 100644 --- a/ruoyi-admin/src/main/java/org/dromara/app/AppMemberController.java +++ b/ruoyi-admin/src/main/java/org/dromara/app/AppMemberController.java @@ -24,6 +24,7 @@ import com.wzj.soopin.transaction.convert.ChargeConvert; import com.wzj.soopin.transaction.convert.WithdrawConvert; import com.wzj.soopin.transaction.domain.bo.ChargeBO; import com.wzj.soopin.transaction.domain.bo.WithdrawBO; +import com.wzj.soopin.transaction.domain.entity.InitiateBatchTransferResponseNew; import com.wzj.soopin.transaction.domain.po.Withdraw; import com.wzj.soopin.transaction.enums.WithdrawType; import com.wzj.soopin.transaction.service.IAccountBillService; @@ -198,20 +199,14 @@ public class AppMemberController { //获取用户信息 LoginUser loginUser = LoginHelper.getLoginUser(); if (loginUser == null) { - throw new ServiceException("用户未登录"); + return R.notLogin(); } Long memberId = loginUser.getUserId(); bo.setMemberId(memberId); bo.setType(WithdrawType.WALLET.getCode()); Withdraw withdraw=withdrawConvert.toPo(bo); - withdrawService.withdraw(withdraw); - //检查提现金额是否小于1元 一元以下自动审批 - if(withdraw.getMoney().compareTo(BigDecimal.ONE)<0){ - //自动审批 - bo.setId(withdraw.getId()); - withdrawService.audit(bo); - } - return R.ok(); + InitiateBatchTransferResponseNew responseNew= withdrawService.withdraw(withdraw); + return R.ok(responseNew); } diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index acde6acd0..49f7d0188 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -263,3 +263,18 @@ easypay: easypay-public-key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCLLVY70e67BcK4V08P+69dfBeMmMYDopf3HF9G6meqPTVxyGYlEb0XwT0UA6g8t2HzG8FaKgTFKgOvhr+EFbBcF+AYdrgFYZSjR4hWBkWiOyKC66wQ7kQhYzC4kwetcDp5TftJfSivbAC1Lm8/Gf2+ZpaDuHDPjLCFS2gQYI5dqwIDAQAB merRsaPrivateKey: MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDLgDEh0hsPTmHLdEYp6LCo3LMnXMLCV+wUxRn5lvcAa7gn8RZyLDGiT5WdR0SpJDbBhWL1WbnUd7dJulzjbb0N5NzrO3HntjzKIpzhfChw3BBtM3PR8xvS17Wt4vRN6JvY4w0sphKesxbHWiMHUTOzKrQBEdXasMujmxV0N9R2GzTLK0Pn4ROWCzeQQhiJ2oOc3Eqkus9/C+3LcxkU08nF/q0X/kzHGS+Gn+JL/Eo6vpQExg7rQs0mnrKvNuPPGKpyKNpRmKWtiA8GTBeFnwZlP8kYj9Z8NYxLsT6fJnqpZ8wZ++T2PS7CIIo6JSIKz0ElNrRRD2Ei7lyCZAjUBQLLAgMBAAECggEBAIRjGhOBhx8XA+IC+55KBZtlMJub6gvafPgqHbLUtk2sxjodylduTr/j/FY8RfuvVnvhFba9r3n8g93QApvmCUafq+TQYFK4qKVrjRnX1stNLtaL0X41JNWuhT/hVMPWXoTjeO+h/p0Frvzzs7QP7I1Ta5UCkFhcCa6etn9Lzskh0uXe4ylMmt5lAuvFAeIuE5icMxu4n75RXUVxaBSOKjQ0ujQWMh46ncrX0f9oGkDbWE37LF61sf2iuXpPwnIAwk/e/zOpnCi9EHOJtCJbVr+ncDRvlZsEf5hVnxYgT2bQrUrSD9An5e4zgJF6rigsDhmNfvp0W/bJnXPIIg1MYbECgYEA5MlizB9XmttiaAo9sMjAUE18cxgEq2pIU8l8WOj//XAsf64AlrlWJxpJdNYkfdRiinnRIxroo1cYx3RMWqdGbYVk+7DJBNut6R0bg0oGgJoeBFnCe/xNGsBk7MZwc4//5sfRC2rbtuYFPn8VkNB5HllhddD51L6lObAp4Uf14/MCgYEA47TX4AmgcAjhVCUvC4ZUuiqAku/suw0vkG6FNxuSYY5GezPvwWx0JviaohaIm5JkgjNNASFhx12XG+PZoPDNGi9vzotVkI69LnOdlf3imVaJR7u5H8730Thbdd5oKi66KYXJGv3hppwh7qAu8VkdMavvCT24jILNiGiA0OBOE8kCgYA5JoFSgiXNHi5X1O8SISPBK4oB6icIdtU4cOVqBFImCgZjoqCtBgEaZXuh/vhAonQ3KTTv8wHYA6LB+DA2mQCDzUWrhb7BQusPh2DfC/fR2i3TYmStuhm8rADKEMv4YilHifSTSI84Af+fW/mUIi+PQD6TQq+V0EXPwkzD5MjstwKBgQDZZ5SlBwvza8cXe9kK+9pxVJslr4UqolBDagIut1hvZFPO1auX1WCgxMN+9ly/jGoCFdDzv1eH7ceUjVr/2mk5EwmA/m9XcbEWZLSUvK5ZENJJduYthIH/c/t+8jYp8Cs18dIsvzFuzatoFfA75oWFI086V3+YSFrMXlp/E2n4YQKBgQCKiynvzcRA8GoK6ibGhwUc5lpVUOVqpIdmVG5bXbnKYoU7Jkf2pOUwzLQGKOj9KS1Z80jkZA9p22BLKD/VXF0PvGBhx1Ujpil1vd96I/KcGRsmCu3b3AxK7qzDt6Y0nVk5bN72RYq4F/iRU+ijoIAuLsyrn/e0eaJweiqzr/gK4g== trade-backUrl: http://43.143.227.203:8880/trans/easypay/trade/callback +wechat: + pay: + v3: + mch-id: 1658665710 # 商户号 + mch-serial-no: 6BA681D9B219034D6F7851F57D61BE9317AB48FD # 商户证书序列号 + api-v3-key: T9iE71aHSmjtM35z4bDLuU3gFX8s2I2h # APIv3密钥 + private-key-path: "/java/cert/apiclient_key.pem" # 商户私钥文件路径 + transfer-notify-url: https://wuzhongjie.com.cn/prod-api/api/transfer/callback # 转账回调地址 + app-id: wxebcdaea31881caab # 应用ID + secret: your_wechat_secret # 应用密钥 + mini-program: + # app-id: wx87a5db19138da60d + # secret: 856ca8bae38ccaecc1353c9abedf6b41 + app-id: wx2fb87f0f1f05d314 + secret: 86fbcab880e4066ac5c75af6f4f003c2 diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index b1f800ced..f57a3d85a 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -120,10 +120,10 @@ security: - /*/api-docs - /*/api-docs/** - /warm-flow-ui/token-name - - /app/** - /resource/oss/** - /callback/api - /cms/vlog/vodCallBack + - /api/transfer/callback # 多租户配置 tenant: @@ -378,21 +378,7 @@ tencent: app-id: 1323742234 # 点播应用ID -wechat: - pay: - v3: - mch-id: 1658665710 # 商户号 - mch-serial-no: 6BA681D9B219034D6F7851F57D61BE9317AB48FD # 商户证书序列号 - api-v3-key: T9iE71aHSmjtM35z4bDLuU3gFX8s2I2h # APIv3密钥 - private-key-path: "classpath:cert/apiclient_key.pem" # 商户私钥文件路径 - transfer-notify-url: https://wuzhongjie.com.cn/prod-api/api/transfer/callback # 转账回调地址 - app-id: wxebcdaea31881caab # 应用ID - secret: your_wechat_secret # 应用密钥 - mini-program: -# app-id: wx87a5db19138da60d -# secret: 856ca8bae38ccaecc1353c9abedf6b41 - app-id: wx2fb87f0f1f05d314 - secret: 86fbcab880e4066ac5c75af6f4f003c2 + http: client: diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/R.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/R.java index fd457b0f9..94c383beb 100644 --- a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/R.java +++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/R.java @@ -40,7 +40,7 @@ public class R implements Serializable { return restResult(null, SUCCESS, "操作成功"); } public static R notLogin() { - return restResult(null, ResultCode.USER_NOT_LOGIN.code(), "操作成功"); + return restResult(null, ResultCode.USER_CONNECT_LOGIN_ERROR.code(),ResultCode.USER_CONNECT_LOGIN_ERROR.message()); } public static R ok(T data) { diff --git a/ruoyi-common/ruoyi-common-satoken/src/main/java/org/dromara/common/satoken/handler/SaTokenExceptionHandler.java b/ruoyi-common/ruoyi-common-satoken/src/main/java/org/dromara/common/satoken/handler/SaTokenExceptionHandler.java index 76c926bf6..2632f0d19 100644 --- a/ruoyi-common/ruoyi-common-satoken/src/main/java/org/dromara/common/satoken/handler/SaTokenExceptionHandler.java +++ b/ruoyi-common/ruoyi-common-satoken/src/main/java/org/dromara/common/satoken/handler/SaTokenExceptionHandler.java @@ -46,7 +46,7 @@ public class SaTokenExceptionHandler { public R handleNotLoginException(NotLoginException e, HttpServletRequest request) { String requestURI = request.getRequestURI(); log.error("请求地址'{}',认证失败'{}',无法访问系统资源", requestURI, e.getMessage()); - return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "认证失败,无法访问系统资源:" + e.getMessage()); + return R.notLogin(); } } diff --git a/ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java b/ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java index 97d06bfef..2c4245205 100644 --- a/ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java +++ b/ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java @@ -75,13 +75,6 @@ public class SecurityConfig implements WebMvcConfigurer { // "-100", "客户端ID与Token不匹配", // StpUtil.getTokenValue()); // } - - // 有效率影响 用于临时测试 - // if (log.isDebugEnabled()) { - // log.info("剩余有效时间: {}", StpUtil.getTokenTimeout()); - // log.info("临时有效时间: {}", StpUtil.getTokenActivityTimeout()); - // } - }); })).addPathPatterns("/**") // 排除不需要拦截的路径 diff --git a/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java b/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java index d96cd1ca7..1581484e7 100644 --- a/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java +++ b/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package org.dromara.common.web.handler; +import cn.dev33.satoken.exception.NotLoginException; import cn.hutool.core.util.ObjectUtil; import cn.hutool.http.HttpStatus; import jakarta.servlet.ServletException; @@ -180,4 +181,13 @@ public class GlobalExceptionHandler { return R.fail(message); } + /** + * 自定义验证异常 + */ + @ExceptionHandler(NotLoginException.class) + public R handleNotLoginException(NotLoginException e) { + log.error(e.getMessage()); + return R.notLogin(); + } + } diff --git a/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/convert/impl/AccountBillConvertImpl.java b/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/convert/impl/AccountBillConvertImpl.java new file mode 100644 index 000000000..6deb5bff3 --- /dev/null +++ b/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/convert/impl/AccountBillConvertImpl.java @@ -0,0 +1,23 @@ +package com.wzj.soopin.member.convert.impl; + +import com.wzj.soopin.member.domain.po.AccountBill; +import com.wzj.soopin.member.domain.vo.AccountBillVO; +import com.wzj.soopin.member.enums.AccountBillSourceEnum; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +/** + * 会员账户表 DO <=> DTO <=> VO / BO / Query + * + * @author zcc + */ +@Component("accountBillConvert") +@Primary +public class AccountBillConvertImpl extends com.wzj.soopin.member.convert.AccountBillConvertImpl { + @Override + public AccountBillVO toVO(AccountBill t) { + AccountBillVO vo= super.toVO(t); + vo.setSourceName(AccountBillSourceEnum.getByCode(t.getSource()).getMessage()); + return vo; + } +} diff --git a/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/domain/vo/AccountBillVO.java b/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/domain/vo/AccountBillVO.java index 0dfe81b04..86d8fe7bb 100644 --- a/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/domain/vo/AccountBillVO.java +++ b/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/domain/vo/AccountBillVO.java @@ -54,6 +54,10 @@ public class AccountBillVO extends BaseAudit { @Excel(name = "来源") private Integer source; + @Schema(description ="来源") + @Excel(name = "来源") + private String sourceName; + @Schema(description ="会员id") private Long memberId; diff --git a/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/enums/AccountBillSourceEnum.java b/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/enums/AccountBillSourceEnum.java index 601fc4718..612082d39 100644 --- a/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/enums/AccountBillSourceEnum.java +++ b/ruoyi-modules/ruoyi-member/src/main/java/com/wzj/soopin/member/enums/AccountBillSourceEnum.java @@ -25,6 +25,14 @@ public enum AccountBillSourceEnum { this.code = code; this.message = message; } + public static AccountBillSourceEnum getByCode(Integer code) { + for (AccountBillSourceEnum anEnum : AccountBillSourceEnum.values()) { + if (anEnum.getCode().equals(code)) { + return anEnum; + } + } + return null; + } public Integer getCode() { return code; diff --git a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/TransferToUser.java b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/TransferToUser.java new file mode 100644 index 000000000..464506e41 --- /dev/null +++ b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/TransferToUser.java @@ -0,0 +1,197 @@ +package com.wzj.soopin.transaction.controller; + + +import com.google.gson.annotations.SerializedName; +import com.google.gson.annotations.Expose; +import com.wzj.soopin.transaction.util.WXPayUtility; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 发起转账 + */ +public class TransferToUser { + private static String HOST = "https://api.mch.weixin.qq.com"; + private static String METHOD = "POST"; + private static String PATH = "/v3/fund-app/mch-transfer/transfer-bills"; + + public static void main(String[] args) { + // TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756 + TransferToUser client = new TransferToUser( + "1658665710", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756 + "6BA681D9B219034D6F7851F57D61BE9317AB48FD", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053 + "D:\\Workspace\\wzj-boot\\ruoyi-admin\\src\\main\\resources\\cert\\apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径 + "PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816 + "D:\\Workspace\\wzj-boot\\ruoyi-admin\\src\\main\\resources\\cert\\pub.key" // 微信支付公钥文件路径,本地文件路径 + ); + + TransferToUserRequest request = new TransferToUserRequest(); + request.appid = "wxebcdaea31881caab"; + request.outBillNo = "plfk2020042013"; + request.transferSceneId = "1005"; + request.openid = "o-ox3uhvt23jLpQT37CriWHotQlxpw"; +// request.userName = client.encrypt("user_name"); + request.transferAmount =1l; + request.transferRemark = "新会员开通有礼"; + request.notifyUrl = "https://wuzhongjie.com.cn/prod-api/api/transfer/callback"; + request.userRecvPerception = "现金奖励"; + request.transferSceneReportInfos = new ArrayList<>(); + { + TransferSceneReportInfo transferSceneReportInfosItem0 = new TransferSceneReportInfo(); + transferSceneReportInfosItem0.infoType = "活动名称"; + transferSceneReportInfosItem0.infoContent = "新会员有礼"; + request.transferSceneReportInfos.add(transferSceneReportInfosItem0); + TransferSceneReportInfo transferSceneReportInfosItem1 = new TransferSceneReportInfo(); + transferSceneReportInfosItem1.infoType = "奖励说明"; + transferSceneReportInfosItem1.infoContent = "注册会员抽奖一等奖"; + request.transferSceneReportInfos.add(transferSceneReportInfosItem1); + }; + try { + TransferToUserResponse response = client.run(request); + // TODO: 请求成功,继续业务逻辑 + System.out.println(response); + } catch (WXPayUtility.ApiException e) { + // TODO: 请求失败,根据状态码执行不同的逻辑 + e.printStackTrace(); + } + } + + public TransferToUserResponse run(TransferToUserRequest 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, TransferToUserResponse.class); + } else { + throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers()); + } + } catch (IOException e) { + throw new UncheckedIOException("Sending request to " + uri + " failed.", e); + } + } + + 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 String encrypt(String plainText) { + return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText); + } + + 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 = new ArrayList(); + } + + 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 static class TransferSceneReportInfo { + @SerializedName("info_type") + public String infoType; + + @SerializedName("info_content") + public String infoContent; + } + + 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 + } + +} diff --git a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/WithdrawController.java b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/WithdrawController.java index 1502b76b0..cc4c6b584 100644 --- a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/WithdrawController.java +++ b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/WithdrawController.java @@ -6,17 +6,29 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.wzj.soopin.member.annotation.MemberFillMethod; import com.wzj.soopin.transaction.convert.WithdrawConvert; import com.wzj.soopin.transaction.domain.bo.WithdrawBO; +import com.wzj.soopin.transaction.domain.entity.TransferDetailEntityNew; import com.wzj.soopin.transaction.domain.po.Withdraw; import com.wzj.soopin.transaction.domain.vo.WithdrawVO; +import com.wzj.soopin.transaction.enums.WithdrawStatus; import com.wzj.soopin.transaction.service.IWithdrawService; +import com.wzj.soopin.transaction.service.impl.WxPayService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; import org.dromara.common.log.annotation.Log; import org.dromara.common.log.enums.BusinessType; +import org.mapstruct.Context; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; +import java.util.Map; + /** * 用户封禁 */ @@ -24,10 +36,12 @@ import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/trans/withdraw") @RequiredArgsConstructor +@Slf4j public class WithdrawController { private final IWithdrawService service; private final WithdrawConvert convert; + private final WxPayService wxPayService; @Operation(summary = "查询提现列表") @PostMapping("/list") @@ -65,4 +79,34 @@ public class WithdrawController { public R remove(@PathVariable Long id) { return R.ok(service.removeById(id)); } + + + /** + * 微信商户零线转账 - 回调通知 + * + * @return + * @Context注解 把HTTP请求上下文对象注入进来,HttpServletRequest、HttpServletResponse、UriInfo 等 + */ + @Operation(summary = "微信商户零线转账 - 回调通知") + @RequestMapping(value = "/callback", method = {RequestMethod.GET, RequestMethod.POST}) + @Log(title = "微信商户零线转账 - 回调通知", businessType = BusinessType.INSERT) + public R wxPayCallback(@Context HttpServletRequest request) { + log.info("微信商户零线转账 - 回调通知 /wxpay/callback"); + TransferDetailEntityNew transferDetailEntityNew = wxPayService.wxPaySuccessCallback(request); + //获取提现的id + Long id = Long.valueOf(transferDetailEntityNew.getOutBillNo()); + //更新提现状态 + service.withdrawCallback(id); + return R.ok(); + + } + + + @Operation(summary = "撤销转账") + @Log(title = "撤销转账", businessType = BusinessType.DELETE) + @DeleteMapping("/cancel{id}") + public R cancel(@PathVariable Long id) { + return R.ok(service.removeById(id)); + } + } diff --git a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/WxPayController.java b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/WxPayController.java index 0ae55ab7a..b8c4ae645 100644 --- a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/WxPayController.java +++ b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/controller/WxPayController.java @@ -1,5 +1,7 @@ package com.wzj.soopin.transaction.controller; +import com.wzj.soopin.member.domain.po.Member; +import com.wzj.soopin.member.service.IMemberService; import com.wzj.soopin.transaction.domain.entity.*; import com.wzj.soopin.transaction.service.impl.WxAuthService; import com.wzj.soopin.transaction.service.impl.WxPayService; @@ -13,9 +15,11 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.dromara.common.core.domain.R; +import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.log.annotation.Log; import org.dromara.common.log.enums.BusinessType; +import org.dromara.common.satoken.utils.LoginHelper; import org.mapstruct.Context; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -40,6 +44,12 @@ public class WxPayController { @Autowired private WxAuthService wxAuthService; + @Autowired + private IMemberService memberService; + + @Autowired + private WechatPayConfig wechatPayConfig; + /** *商家转账 - 发起转账 * @return @@ -60,7 +70,7 @@ public class WxPayController { // /** 转账场景ID 说明:该批次转账使用的转账场景,如不填写则使用商家的默认场景,如无默认场景可为空,可前往“商家转账到零钱-前往功能”中申请。 如:1001-现金营销 */ request.setTransferSceneId("1005"); //用户的openId - request.setOpenid("ox3uhvt23jLpQT37CriWHotQlxpw"); + request.setOpenid("oICLw6vRuRFK4XMt1EGA2-yxxHug"); //收款用户姓名 request.setUserName(""); //转账备注 @@ -93,7 +103,7 @@ public class WxPayController { * @return */ @Operation(summary = "微信商户零线转账 - 回调通知") - @PostMapping("/callback") + @RequestMapping(value = "/callback", method = {RequestMethod.GET, RequestMethod.POST}) @Log(title = "微信商户零线转账 - 回调通知", businessType = BusinessType.INSERT) public ResponseEntity> wxPayCallback(@Context HttpServletRequest request) { Map errMap = new HashMap<>(); @@ -180,7 +190,44 @@ public class WxPayController { } } + /** + * 获取用户的openid + * + * @return 包含openid的响应对象 + */ + @Operation(summary = "拉取用户授权") + @GetMapping("/pullConfirm") + @Parameters({ + @Parameter(name = "code", description = "授权码", required = true, in = ParameterIn.QUERY) + }) + public R pullComfirm() { + try { + InitiateBatchTransferRequestNew request = new InitiateBatchTransferRequestNew(); + //商户AppID + request.setAppid(wechatPayConfig.getAppId()); + LoginUser user = LoginHelper.getLoginUser(); + Member member = memberService.getById(user.getUserId()); + //用户的openId + request.setOpenid(member.getOpenId()); + //收款用户姓名 + //转账场景ID + // /** 转账场景ID 说明:该批次转账使用的转账场景,如不填写则使用商家的默认场景,如无默认场景可为空,可前往“商家转账到零钱-前往功能”中申请。 如:1001-现金营销 */ + request.setTransferSceneId("1005"); + //【转账备注】 单条转账备注(微信用户会收到该备注),UTF8编码,最多允许32个字符 + + InitiateBatchTransferResponseNew response =null; + try{ + response = wxPayService.pullConfirm(request); + }catch (ServiceException e){ + e.printStackTrace(); + } + return R.ok(response); + } catch (Exception e) { + log.error("获取openid时发生异常: {}", e.getMessage()); + return R.fail("发起面确认收款授权时发生异常: " + e.getMessage()); + } + } diff --git a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/domain/entity/InitiateBatchTransferRequestNew.java b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/domain/entity/InitiateBatchTransferRequestNew.java index 6bb9cdd42..bb6b4b86d 100644 --- a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/domain/entity/InitiateBatchTransferRequestNew.java +++ b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/domain/entity/InitiateBatchTransferRequestNew.java @@ -58,7 +58,7 @@ public class InitiateBatchTransferRequestNew { /** 转账场景报备信息 Y 说明:各转账场景下需报备的内容,可通过 产品文档 了解 */ @SerializedName("transfer_scene_report_infos") - private List transferSceneReportInfos = new ArrayList<>(); + private List transferSceneReportInfos ; public InitiateBatchTransferRequestNew() { super(); diff --git a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/IWithdrawService.java b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/IWithdrawService.java index d91d6c981..ea420c09f 100644 --- a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/IWithdrawService.java +++ b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/IWithdrawService.java @@ -2,13 +2,14 @@ package com.wzj.soopin.transaction.service; import com.baomidou.mybatisplus.extension.service.IService; import com.wzj.soopin.transaction.domain.bo.WithdrawBO; +import com.wzj.soopin.transaction.domain.entity.InitiateBatchTransferResponseNew; import com.wzj.soopin.transaction.domain.po.Withdraw; public interface IWithdrawService extends IService { boolean audit(WithdrawBO bo); - boolean withdrawCallback(WithdrawBO withdraw); + boolean withdrawCallback(Long id); - boolean withdraw (Withdraw withdraw); + InitiateBatchTransferResponseNew withdraw (Withdraw withdraw); } diff --git a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/AccountBillServiceImpl.java b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/AccountBillServiceImpl.java index d016b0294..00aab2831 100644 --- a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/AccountBillServiceImpl.java +++ b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/AccountBillServiceImpl.java @@ -76,6 +76,7 @@ public class AccountBillServiceImpl extends if (memberAccount == null) { throw new RuntimeException("用户不存在"); } + BigDecimal balance = memberAccount.getWallet(); BigDecimal newBalance = balance.add(money); //锁定用户余额 diff --git a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/WithdrawServiceImpl.java b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/WithdrawServiceImpl.java index c7ff2bb76..da784522c 100644 --- a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/WithdrawServiceImpl.java +++ b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/WithdrawServiceImpl.java @@ -1,5 +1,7 @@ package com.wzj.soopin.transaction.service.impl; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.wzj.soopin.content.domain.po.Comment; import com.wzj.soopin.member.domain.po.Member; @@ -7,6 +9,7 @@ import com.wzj.soopin.transaction.domain.bo.WithdrawBO; import com.wzj.soopin.member.domain.po.MemberAccount; import com.wzj.soopin.member.domain.po.AccountBill; import com.wzj.soopin.transaction.domain.entity.InitiateBatchTransferRequestNew; +import com.wzj.soopin.transaction.domain.entity.InitiateBatchTransferResponseNew; import com.wzj.soopin.transaction.domain.entity.TransferSceneReportInfoNew; import com.wzj.soopin.transaction.domain.po.Withdraw; import com.wzj.soopin.member.enums.AccountBillChangeTypeEnum; @@ -30,9 +33,11 @@ import org.dromara.common.mq.domain.MQMessage; import org.dromara.common.mq.enums.MessageActionEnum; import org.dromara.common.mq.utils.MqUtil; import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.common.translation.annotation.Translation; import org.dromara.system.domain.SysTenantAccount; import org.dromara.system.service.ISysTenantAccountService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -49,15 +54,6 @@ import java.util.*; public class WithdrawServiceImpl extends ServiceImpl implements IWithdrawService { private final IMemberAccountService memberAccountService; - - private final ISysTenantAccountService sysTenantAccountService; - - - /** - * 易生账户充值服务 - */ - private final IEasypayService easypayService; - private final IAccountBillService accountBillService; private final WxPayService wxPayService; @@ -76,25 +72,7 @@ public class WithdrawServiceImpl extends ServiceImpl i } //发起提现 - try{ - wxPayService.initiateBatchTransferNew(buildWechatPayParam(withdraw)); - }catch (ServiceException e){ - log.error(e.getMessage()); - //提现失败 - //发送通知 - MqUtil.sendIMMessage(buildMessage(withdraw,MessageActionEnum.ORDER_WITHDRAW_AUDIT)); - throw new ServiceException(ResultCode.WITHDRAW_AUDIT_ERROR); - } - withdraw = Withdraw.builder().id(bo.getId()) - .auditReason(bo.getAuditReason()) - .auditTime(LocalDateTime.now()) - .auditStatus(bo.getAuditStatus()) - .auditBy(LoginHelper.getUserId()) - .build(); - this.updateById(withdraw); - //发送成功通知 - MqUtil.sendIMMessage(buildMessage(withdraw, MessageActionEnum.ORDER_WITHDRAW_AUDIT)); return true; } @@ -113,19 +91,12 @@ public class WithdrawServiceImpl extends ServiceImpl i } - - //sender - //receiver - //object - //action - - private InitiateBatchTransferRequestNew buildWechatPayParam(Withdraw withdraw) { InitiateBatchTransferRequestNew request = new InitiateBatchTransferRequestNew(); //商户AppID request.setAppid(wechatPayConfig.getAppId()); //商户单号 - request.setOutBillNo(withdraw.getCode()); + request.setOutBillNo(withdraw.getId()+""); request.setTransferAmount(withdraw.getMoney().multiply(BigDecimal.valueOf(100)).intValue()); //转账场景ID // /** 转账场景ID 说明:该批次转账使用的转账场景,如不填写则使用商家的默认场景,如无默认场景可为空,可前往“商家转账到零钱-前往功能”中申请。 如:1001-现金营销 */ @@ -160,26 +131,17 @@ public class WithdrawServiceImpl extends ServiceImpl i } @Override - public boolean withdrawCallback(WithdrawBO withdraw) { - MemberAccount memberAccount = memberAccountService.getMemberAccount(withdraw.getMemberId()); - BigDecimal balance = memberAccount.getWallet(); - ////提现成功后,更新会员账户余额 - //从易生取,别用自己计算的 - //// TODO: 2025/6/21 测试的时候用计算的 测试完用易生的 - BigDecimal finalBalance = balance.subtract(withdraw.getMoney()); - EasypayAccountVO easypayAccountVO = easypayService.getEasypayAccount(withdraw.getMemberId()); - memberAccountService.updateById(memberAccount.toBuilder().wallet(finalBalance).build()); - //生成账户变动记录bh - AccountBill memberAccountChangeRecord = AccountBill.builder() - .accountId(withdraw.getMemberId()) - .moneyBalance(finalBalance) - .beforeBalance(balance) - .afterBalance(easypayAccountVO.getBalance()) - .changeType(AccountBillChangeTypeEnum.OUT.getCode()) - .changeDesc("提现") - .source(AccountBillSourceEnum.WITHDRAW.getCode()).build(); - accountBillService.save(memberAccountChangeRecord); + public boolean withdrawCallback(Long id) { + Withdraw withdraw = getById(id); + if (withdraw == null) { + throw new RuntimeException("提现申请不存在"); + } + if (!Objects.equals(WithdrawStatus.PENDING.getCode(), withdraw.getStatus())) { + throw new RuntimeException("提现申请已处理"); + } + withdraw.setStatus(WithdrawStatus.SUCCESS.getCode()); + updateById(withdraw); return true; } @@ -191,7 +153,9 @@ public class WithdrawServiceImpl extends ServiceImpl i - public boolean withdraw (Withdraw withdraw) { + @Override + @Transactional + public InitiateBatchTransferResponseNew withdraw (Withdraw withdraw) { MemberAccount memberAccount = memberAccountService.getMemberAccount(withdraw.getMemberId()); if (memberAccount == null) { throw new RuntimeException("用户不存在"); @@ -201,6 +165,16 @@ public class WithdrawServiceImpl extends ServiceImpl i if (balance==null||balance.compareTo(withdraw.getMoney()) < 0) { throw new RuntimeException("用户余额不足"); } + //检查提现次数是否超过限制 + long count = this.count(new LambdaQueryWrapper() + .eq(Withdraw::getMemberId, withdraw.getMemberId()) + .eq(Withdraw::getStatus, WithdrawStatus.SUCCESS.getCode()) + .between(Withdraw::getCreateTime, LocalDateTime.now().minusDays(1), LocalDateTime.now()) + + ); + if (count >= 3) { + throw new RuntimeException("提现次数超过限制"); + } //生成费用 BigDecimal fee = withdraw.getMoney().multiply(new BigDecimal("0.00")); withdraw.setFee(fee); @@ -223,6 +197,23 @@ public class WithdrawServiceImpl extends ServiceImpl i .source(AccountBillSourceEnum.WITHDRAW.getCode()) .build(); accountBillService.save(memberAccountChangeRecord); - return true; + + InitiateBatchTransferResponseNew response = null; + try{ + response = wxPayService.initiateBatchTransferNew(buildWechatPayParam(withdraw)); + }catch (ServiceException e){ + log.error(e.getMessage()); + throw new ServiceException(e.getMessage()); + } + withdraw = Withdraw.builder().id(withdraw.getId()) + .auditReason(withdraw.getAuditReason()) + .auditTime(LocalDateTime.now()) + .status(WithdrawStatus.PENDING.getCode()) + .build(); + this.updateById(withdraw); + + //发送成功通知 + MqUtil.sendIMMessage(buildMessage(withdraw, MessageActionEnum.ORDER_WITHDRAW_AUDIT)); + return response; } } diff --git a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/WxPayService.java b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/WxPayService.java index 85ac0dc8b..a1a60a762 100644 --- a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/WxPayService.java +++ b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/service/impl/WxPayService.java @@ -3,6 +3,7 @@ package com.wzj.soopin.transaction.service.impl; import cn.hutool.core.io.IoUtil; import com.alibaba.fastjson.JSON; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.wechat.pay.java.core.Config; import com.wechat.pay.java.core.RSAAutoCertificateConfig; import com.wechat.pay.java.core.cipher.PrivacyDecryptor; @@ -17,7 +18,6 @@ import com.wzj.soopin.transaction.domain.entity.TransferBillEntity; import com.wzj.soopin.transaction.domain.entity.TransferDetailEntityNew; import com.wzj.soopin.transaction.wechat.WechatPayConfig; import jakarta.servlet.http.HttpServletRequest; -import org.dromara.common.core.constant.ResultCode; import org.dromara.common.core.exception.ServiceException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,7 +44,7 @@ public class WxPayService { @Autowired private WechatPayConfig WechatPayConfig; @Autowired - private RSAAutoCertificateConfig wxPayConfig; + private RSAAutoCertificateConfig config; /** * 商家转账 - 发起转账 - 2025年1月15号之后,商户转账零线必须用户确认收款 @@ -54,16 +54,16 @@ public class WxPayService { */ public InitiateBatchTransferResponseNew initiateBatchTransferNew(InitiateBatchTransferRequestNew request) { logger.info("WxPayService.initiateBatchTransferNew request:{}", request.toString()); - if(request.getUserName()!=null){ - String encryptName = wxPayConfig.createEncryptor().encrypt(request.getUserName()); - request.setUserName(encryptName); - } + if(request.getUserName() != null && !request.getUserName().isEmpty()){ + String encryptName = config.createEncryptor().encrypt(request.getUserName()); + request.setUserName(encryptName); + } String requestPath = "https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/transfer-bills"; HttpHeaders headers = new HttpHeaders(); headers.addHeader("Accept", MediaType.APPLICATION_JSON.getValue()); headers.addHeader("Content-Type", MediaType.APPLICATION_JSON.getValue()); - headers.addHeader("Wechatpay-Serial", wxPayConfig.createEncryptor().getWechatpaySerial()); + headers.addHeader("Wechatpay-Serial", config.createEncryptor().getWechatpaySerial()); HttpRequest httpRequest = new HttpRequest.Builder() .httpMethod(HttpMethod.POST) @@ -71,7 +71,38 @@ public class WxPayService { .headers(headers) .body(createRequestBody(request)) .build(); - HttpClient httpClient = new DefaultHttpClientBuilder().config(wxPayConfig).build(); + HttpClient httpClient = new DefaultHttpClientBuilder().config(config).build(); + HttpResponse httpResponse = httpClient.execute(httpRequest, InitiateBatchTransferResponseNew.class); + logger.info("WxPayService.initiateBatchTransferNew response:{}", httpResponse.getServiceResponse()); + return httpResponse.getServiceResponse(); + } + + /** + * 商家转账 - 发起转账 - 2025年1月15号之后,商户转账零线必须用户确认收款 + * + * @param request 请求体 + * @return + */ + public InitiateBatchTransferResponseNew pullConfirm(InitiateBatchTransferRequestNew request) { + logger.info("WxPayService.initiateBatchTransferNew request:{}", request.toString()); + if(request.getUserName() != null && !request.getUserName().isEmpty()){ + String encryptName = config.createEncryptor().encrypt(request.getUserName()); + request.setUserName(encryptName); + } + + String requestPath = "https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/user-confirm-authorization"; + HttpHeaders headers = new HttpHeaders(); + headers.addHeader("Accept", MediaType.APPLICATION_JSON.getValue()); + headers.addHeader("Content-Type", MediaType.APPLICATION_JSON.getValue()); + headers.addHeader("Wechatpay-Serial", config.createEncryptor().getWechatpaySerial()); + HttpRequest httpRequest = + new HttpRequest.Builder() + .httpMethod(HttpMethod.POST) + .url(requestPath) + .headers(headers) + .body(createRequestBody(request)) + .build(); + HttpClient httpClient = new DefaultHttpClientBuilder().config(config).build(); HttpResponse httpResponse = httpClient.execute(httpRequest, InitiateBatchTransferResponseNew.class); logger.info("WxPayService.initiateBatchTransferNew response:{}", httpResponse.getServiceResponse()); return httpResponse.getServiceResponse(); @@ -85,13 +116,6 @@ public class WxPayService { */ public TransferDetailEntityNew getTransferDetailByOutNoNew(String outBillNo) { logger.info("WxPayService.getTransferDetailByOutNoNew request:{}", outBillNo); - - Config config = new RSAAutoCertificateConfig.Builder() - .merchantId(WechatPayConfig.getMchId()) - .privateKeyFromPath(WechatPayConfig.getPrivateKeyPath()) - .merchantSerialNumber(WechatPayConfig.getMchSerialNo()) - .apiV3Key(WechatPayConfig.getApiV3Key()) - .build(); String requestPath = "https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{out_bill_no}"; requestPath = requestPath.replace("{out_bill_no}", UrlEncoder.urlEncode(outBillNo)); HttpHeaders headers = new HttpHeaders(); @@ -117,7 +141,7 @@ public class WxPayService { * @return */ private static RequestBody createRequestBody(Object request) { - return new JsonRequestBody.Builder().body(new Gson().toJson(request)).build(); + return new JsonRequestBody.Builder().body(new GsonBuilder().create().toJson(request)).build(); } /** @@ -145,15 +169,9 @@ public class WxPayService { .body(requestBody) .build(); // 2. 构建Config RSAAutoCertificateConfig - Config config = new RSAAutoCertificateConfig.Builder() - .merchantId(WechatPayConfig.getMchId()) - .privateKeyFromPath(WechatPayConfig.getPrivateKeyPath()) - .merchantSerialNumber(WechatPayConfig.getMchSerialNo()) - .apiV3Key(WechatPayConfig.getApiV3Key()) - .build(); - logger.info("WxPayService.wxPaySuccessCallback request : wechatPaySerial is [{}] , wechatSignature is [{}] , wechatTimestamp is [{}] , wechatpayNonce is [{}] , requestBody is [{}]", wechatPaySerial, wechatSignature, wechatTimestamp, wechatpayNonce, requestBody); + logger.info("WxPayService.wxPaySuccessCallback request : wechatPaySerial is [{}] , wechatSignature is [{}] , wechatTimestamp is [{}] , wechatpayNonce is [{}] , requestBody is [{}]",wechatPaySerial,wechatSignature,wechatTimestamp,wechatpayNonce,requestBody); // 3. 初始化 NotificationParser - NotificationParser parser = new NotificationParser((NotificationConfig) config); + NotificationParser parser = new NotificationParser( config); try { TransferDetailEntityNew entity = parser.parse(requestParam, TransferDetailEntityNew.class); logger.info("WxPayService.wxPaySuccessCallback responseBody: {}", entity != null ? JSON.toJSONString(entity) : null); @@ -240,5 +258,6 @@ public class WxPayService { } + } diff --git a/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/util/WXPayUtility.java b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/util/WXPayUtility.java new file mode 100644 index 000000000..b90622f3f --- /dev/null +++ b/ruoyi-modules/ruoyi-transaction/src/main/java/com/wzj/soopin/transaction/util/WXPayUtility.java @@ -0,0 +1,853 @@ +package com.wzj.soopin.transaction.util; + +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 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.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +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(); + boolean isFirstParam = true; + for (Map.Entry entry : params.entrySet()) { + if (entry.getValue() == null) { + continue; + } + if (!isFirstParam) { + result.append("&"); + } + + String valueString; + Object value = entry.getValue(); + // 如果是基本类型、字符串或枚举,直接转换;如果是对象,序列化为JSON + if (value instanceof String || value instanceof Number || value instanceof Boolean || value instanceof Enum) { + valueString = value.toString(); + } else { + valueString = toJson(value); + } + + result.append(entry.getKey()) + .append("=") + .append(urlEncode(valueString)); + isFirstParam = false; + } + 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"); + 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("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"); + } +}