Merge remote-tracking branch 'origin/wzj-main' into wzj-main
This commit is contained in:
commit
956e4e4001
@ -24,6 +24,7 @@ import com.wzj.soopin.transaction.convert.ChargeConvert;
|
|||||||
import com.wzj.soopin.transaction.convert.WithdrawConvert;
|
import com.wzj.soopin.transaction.convert.WithdrawConvert;
|
||||||
import com.wzj.soopin.transaction.domain.bo.ChargeBO;
|
import com.wzj.soopin.transaction.domain.bo.ChargeBO;
|
||||||
import com.wzj.soopin.transaction.domain.bo.WithdrawBO;
|
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.domain.po.Withdraw;
|
||||||
import com.wzj.soopin.transaction.enums.WithdrawType;
|
import com.wzj.soopin.transaction.enums.WithdrawType;
|
||||||
import com.wzj.soopin.transaction.service.IAccountBillService;
|
import com.wzj.soopin.transaction.service.IAccountBillService;
|
||||||
@ -198,20 +199,14 @@ public class AppMemberController {
|
|||||||
//获取用户信息
|
//获取用户信息
|
||||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||||
if (loginUser == null) {
|
if (loginUser == null) {
|
||||||
throw new ServiceException("用户未登录");
|
return R.notLogin();
|
||||||
}
|
}
|
||||||
Long memberId = loginUser.getUserId();
|
Long memberId = loginUser.getUserId();
|
||||||
bo.setMemberId(memberId);
|
bo.setMemberId(memberId);
|
||||||
bo.setType(WithdrawType.WALLET.getCode());
|
bo.setType(WithdrawType.WALLET.getCode());
|
||||||
Withdraw withdraw=withdrawConvert.toPo(bo);
|
Withdraw withdraw=withdrawConvert.toPo(bo);
|
||||||
withdrawService.withdraw(withdraw);
|
InitiateBatchTransferResponseNew responseNew= withdrawService.withdraw(withdraw);
|
||||||
//检查提现金额是否小于1元 一元以下自动审批
|
return R.ok(responseNew);
|
||||||
if(withdraw.getMoney().compareTo(BigDecimal.ONE)<0){
|
|
||||||
//自动审批
|
|
||||||
bo.setId(withdraw.getId());
|
|
||||||
withdrawService.audit(bo);
|
|
||||||
}
|
|
||||||
return R.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -263,3 +263,18 @@ easypay:
|
|||||||
easypay-public-key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCLLVY70e67BcK4V08P+69dfBeMmMYDopf3HF9G6meqPTVxyGYlEb0XwT0UA6g8t2HzG8FaKgTFKgOvhr+EFbBcF+AYdrgFYZSjR4hWBkWiOyKC66wQ7kQhYzC4kwetcDp5TftJfSivbAC1Lm8/Gf2+ZpaDuHDPjLCFS2gQYI5dqwIDAQAB
|
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==
|
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
|
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
|
||||||
|
@ -120,10 +120,10 @@ security:
|
|||||||
- /*/api-docs
|
- /*/api-docs
|
||||||
- /*/api-docs/**
|
- /*/api-docs/**
|
||||||
- /warm-flow-ui/token-name
|
- /warm-flow-ui/token-name
|
||||||
- /app/**
|
|
||||||
- /resource/oss/**
|
- /resource/oss/**
|
||||||
- /callback/api
|
- /callback/api
|
||||||
- /cms/vlog/vodCallBack
|
- /cms/vlog/vodCallBack
|
||||||
|
- /api/transfer/callback
|
||||||
|
|
||||||
# 多租户配置
|
# 多租户配置
|
||||||
tenant:
|
tenant:
|
||||||
@ -378,21 +378,7 @@ tencent:
|
|||||||
app-id: 1323742234 # 点播应用ID
|
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:
|
http:
|
||||||
client:
|
client:
|
||||||
|
@ -40,7 +40,7 @@ public class R<T> implements Serializable {
|
|||||||
return restResult(null, SUCCESS, "操作成功");
|
return restResult(null, SUCCESS, "操作成功");
|
||||||
}
|
}
|
||||||
public static <T> R<T> notLogin() {
|
public static <T> R<T> 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 <T> R<T> ok(T data) {
|
public static <T> R<T> ok(T data) {
|
||||||
|
@ -46,7 +46,7 @@ public class SaTokenExceptionHandler {
|
|||||||
public R<Void> handleNotLoginException(NotLoginException e, HttpServletRequest request) {
|
public R<Void> handleNotLoginException(NotLoginException e, HttpServletRequest request) {
|
||||||
String requestURI = request.getRequestURI();
|
String requestURI = request.getRequestURI();
|
||||||
log.error("请求地址'{}',认证失败'{}',无法访问系统资源", requestURI, e.getMessage());
|
log.error("请求地址'{}',认证失败'{}',无法访问系统资源", requestURI, e.getMessage());
|
||||||
return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "认证失败,无法访问系统资源:" + e.getMessage());
|
return R.notLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -75,13 +75,6 @@ public class SecurityConfig implements WebMvcConfigurer {
|
|||||||
// "-100", "客户端ID与Token不匹配",
|
// "-100", "客户端ID与Token不匹配",
|
||||||
// StpUtil.getTokenValue());
|
// StpUtil.getTokenValue());
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// 有效率影响 用于临时测试
|
|
||||||
// if (log.isDebugEnabled()) {
|
|
||||||
// log.info("剩余有效时间: {}", StpUtil.getTokenTimeout());
|
|
||||||
// log.info("临时有效时间: {}", StpUtil.getTokenActivityTimeout());
|
|
||||||
// }
|
|
||||||
|
|
||||||
});
|
});
|
||||||
})).addPathPatterns("/**")
|
})).addPathPatterns("/**")
|
||||||
// 排除不需要拦截的路径
|
// 排除不需要拦截的路径
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package org.dromara.common.web.handler;
|
package org.dromara.common.web.handler;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.exception.NotLoginException;
|
||||||
import cn.hutool.core.util.ObjectUtil;
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
import cn.hutool.http.HttpStatus;
|
import cn.hutool.http.HttpStatus;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
@ -180,4 +181,13 @@ public class GlobalExceptionHandler {
|
|||||||
return R.fail(message);
|
return R.fail(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义验证异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(NotLoginException.class)
|
||||||
|
public R<Void> handleNotLoginException(NotLoginException e) {
|
||||||
|
log.error(e.getMessage());
|
||||||
|
return R.notLogin();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -54,6 +54,10 @@ public class AccountBillVO extends BaseAudit {
|
|||||||
@Excel(name = "来源")
|
@Excel(name = "来源")
|
||||||
private Integer source;
|
private Integer source;
|
||||||
|
|
||||||
|
@Schema(description ="来源")
|
||||||
|
@Excel(name = "来源")
|
||||||
|
private String sourceName;
|
||||||
|
|
||||||
@Schema(description ="会员id")
|
@Schema(description ="会员id")
|
||||||
private Long memberId;
|
private Long memberId;
|
||||||
|
|
||||||
|
@ -25,6 +25,14 @@ public enum AccountBillSourceEnum {
|
|||||||
this.code = code;
|
this.code = code;
|
||||||
this.message = message;
|
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() {
|
public Integer getCode() {
|
||||||
return code;
|
return code;
|
||||||
|
@ -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<TransferSceneReportInfo> transferSceneReportInfos = new ArrayList<TransferSceneReportInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -6,17 +6,29 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.wzj.soopin.member.annotation.MemberFillMethod;
|
import com.wzj.soopin.member.annotation.MemberFillMethod;
|
||||||
import com.wzj.soopin.transaction.convert.WithdrawConvert;
|
import com.wzj.soopin.transaction.convert.WithdrawConvert;
|
||||||
import com.wzj.soopin.transaction.domain.bo.WithdrawBO;
|
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.po.Withdraw;
|
||||||
import com.wzj.soopin.transaction.domain.vo.WithdrawVO;
|
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.IWithdrawService;
|
||||||
|
import com.wzj.soopin.transaction.service.impl.WxPayService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.dromara.common.core.domain.R;
|
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.annotation.Log;
|
||||||
import org.dromara.common.log.enums.BusinessType;
|
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 org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户封禁
|
* 用户封禁
|
||||||
*/
|
*/
|
||||||
@ -24,10 +36,12 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/trans/withdraw")
|
@RequestMapping("/trans/withdraw")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class WithdrawController {
|
public class WithdrawController {
|
||||||
|
|
||||||
private final IWithdrawService service;
|
private final IWithdrawService service;
|
||||||
private final WithdrawConvert convert;
|
private final WithdrawConvert convert;
|
||||||
|
private final WxPayService wxPayService;
|
||||||
|
|
||||||
@Operation(summary = "查询提现列表")
|
@Operation(summary = "查询提现列表")
|
||||||
@PostMapping("/list")
|
@PostMapping("/list")
|
||||||
@ -65,4 +79,34 @@ public class WithdrawController {
|
|||||||
public R<Object> remove(@PathVariable Long id) {
|
public R<Object> remove(@PathVariable Long id) {
|
||||||
return R.ok(service.removeById(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<Object> cancel(@PathVariable Long id) {
|
||||||
|
return R.ok(service.removeById(id));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package com.wzj.soopin.transaction.controller;
|
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.domain.entity.*;
|
||||||
import com.wzj.soopin.transaction.service.impl.WxAuthService;
|
import com.wzj.soopin.transaction.service.impl.WxAuthService;
|
||||||
import com.wzj.soopin.transaction.service.impl.WxPayService;
|
import com.wzj.soopin.transaction.service.impl.WxPayService;
|
||||||
@ -13,9 +15,11 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.dromara.common.core.domain.R;
|
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.core.exception.ServiceException;
|
||||||
import org.dromara.common.log.annotation.Log;
|
import org.dromara.common.log.annotation.Log;
|
||||||
import org.dromara.common.log.enums.BusinessType;
|
import org.dromara.common.log.enums.BusinessType;
|
||||||
|
import org.dromara.common.satoken.utils.LoginHelper;
|
||||||
import org.mapstruct.Context;
|
import org.mapstruct.Context;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@ -40,6 +44,12 @@ public class WxPayController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private WxAuthService wxAuthService;
|
private WxAuthService wxAuthService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IMemberService memberService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WechatPayConfig wechatPayConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*商家转账 - 发起转账
|
*商家转账 - 发起转账
|
||||||
* @return
|
* @return
|
||||||
@ -60,7 +70,7 @@ public class WxPayController {
|
|||||||
// /** 转账场景ID 说明:该批次转账使用的转账场景,如不填写则使用商家的默认场景,如无默认场景可为空,可前往“商家转账到零钱-前往功能”中申请。 如:1001-现金营销 */
|
// /** 转账场景ID 说明:该批次转账使用的转账场景,如不填写则使用商家的默认场景,如无默认场景可为空,可前往“商家转账到零钱-前往功能”中申请。 如:1001-现金营销 */
|
||||||
request.setTransferSceneId("1005");
|
request.setTransferSceneId("1005");
|
||||||
//用户的openId
|
//用户的openId
|
||||||
request.setOpenid("ox3uhvt23jLpQT37CriWHotQlxpw");
|
request.setOpenid("oICLw6vRuRFK4XMt1EGA2-yxxHug");
|
||||||
//收款用户姓名
|
//收款用户姓名
|
||||||
request.setUserName("");
|
request.setUserName("");
|
||||||
//转账备注
|
//转账备注
|
||||||
@ -93,7 +103,7 @@ public class WxPayController {
|
|||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
@Operation(summary = "微信商户零线转账 - 回调通知")
|
@Operation(summary = "微信商户零线转账 - 回调通知")
|
||||||
@PostMapping("/callback")
|
@RequestMapping(value = "/callback", method = {RequestMethod.GET, RequestMethod.POST})
|
||||||
@Log(title = "微信商户零线转账 - 回调通知", businessType = BusinessType.INSERT)
|
@Log(title = "微信商户零线转账 - 回调通知", businessType = BusinessType.INSERT)
|
||||||
public ResponseEntity<Map<String, String>> wxPayCallback(@Context HttpServletRequest request) {
|
public ResponseEntity<Map<String, String>> wxPayCallback(@Context HttpServletRequest request) {
|
||||||
Map<String,String> errMap = new HashMap<>();
|
Map<String,String> 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<InitiateBatchTransferResponseNew> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -58,7 +58,7 @@ public class InitiateBatchTransferRequestNew {
|
|||||||
|
|
||||||
/** 转账场景报备信息 Y 说明:各转账场景下需报备的内容,可通过 产品文档 了解 */
|
/** 转账场景报备信息 Y 说明:各转账场景下需报备的内容,可通过 产品文档 了解 */
|
||||||
@SerializedName("transfer_scene_report_infos")
|
@SerializedName("transfer_scene_report_infos")
|
||||||
private List<TransferSceneReportInfoNew> transferSceneReportInfos = new ArrayList<>();
|
private List<TransferSceneReportInfoNew> transferSceneReportInfos ;
|
||||||
|
|
||||||
public InitiateBatchTransferRequestNew() {
|
public InitiateBatchTransferRequestNew() {
|
||||||
super();
|
super();
|
||||||
|
@ -2,13 +2,14 @@ package com.wzj.soopin.transaction.service;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
import com.wzj.soopin.transaction.domain.bo.WithdrawBO;
|
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.domain.po.Withdraw;
|
||||||
|
|
||||||
public interface IWithdrawService extends IService<Withdraw> {
|
public interface IWithdrawService extends IService<Withdraw> {
|
||||||
boolean audit(WithdrawBO bo);
|
boolean audit(WithdrawBO bo);
|
||||||
|
|
||||||
boolean withdrawCallback(WithdrawBO withdraw);
|
boolean withdrawCallback(Long id);
|
||||||
|
|
||||||
boolean withdraw (Withdraw withdraw);
|
InitiateBatchTransferResponseNew withdraw (Withdraw withdraw);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -76,6 +76,7 @@ public class AccountBillServiceImpl extends
|
|||||||
if (memberAccount == null) {
|
if (memberAccount == null) {
|
||||||
throw new RuntimeException("用户不存在");
|
throw new RuntimeException("用户不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
BigDecimal balance = memberAccount.getWallet();
|
BigDecimal balance = memberAccount.getWallet();
|
||||||
BigDecimal newBalance = balance.add(money);
|
BigDecimal newBalance = balance.add(money);
|
||||||
//锁定用户余额
|
//锁定用户余额
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
package com.wzj.soopin.transaction.service.impl;
|
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.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.wzj.soopin.content.domain.po.Comment;
|
import com.wzj.soopin.content.domain.po.Comment;
|
||||||
import com.wzj.soopin.member.domain.po.Member;
|
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.MemberAccount;
|
||||||
import com.wzj.soopin.member.domain.po.AccountBill;
|
import com.wzj.soopin.member.domain.po.AccountBill;
|
||||||
import com.wzj.soopin.transaction.domain.entity.InitiateBatchTransferRequestNew;
|
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.entity.TransferSceneReportInfoNew;
|
||||||
import com.wzj.soopin.transaction.domain.po.Withdraw;
|
import com.wzj.soopin.transaction.domain.po.Withdraw;
|
||||||
import com.wzj.soopin.member.enums.AccountBillChangeTypeEnum;
|
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.enums.MessageActionEnum;
|
||||||
import org.dromara.common.mq.utils.MqUtil;
|
import org.dromara.common.mq.utils.MqUtil;
|
||||||
import org.dromara.common.satoken.utils.LoginHelper;
|
import org.dromara.common.satoken.utils.LoginHelper;
|
||||||
|
import org.dromara.common.translation.annotation.Translation;
|
||||||
import org.dromara.system.domain.SysTenantAccount;
|
import org.dromara.system.domain.SysTenantAccount;
|
||||||
import org.dromara.system.service.ISysTenantAccountService;
|
import org.dromara.system.service.ISysTenantAccountService;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@ -49,15 +54,6 @@ import java.util.*;
|
|||||||
public class WithdrawServiceImpl extends ServiceImpl<WithdrawMapper, Withdraw> implements IWithdrawService {
|
public class WithdrawServiceImpl extends ServiceImpl<WithdrawMapper, Withdraw> implements IWithdrawService {
|
||||||
|
|
||||||
private final IMemberAccountService memberAccountService;
|
private final IMemberAccountService memberAccountService;
|
||||||
|
|
||||||
private final ISysTenantAccountService sysTenantAccountService;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 易生账户充值服务
|
|
||||||
*/
|
|
||||||
private final IEasypayService easypayService;
|
|
||||||
|
|
||||||
private final IAccountBillService accountBillService;
|
private final IAccountBillService accountBillService;
|
||||||
|
|
||||||
private final WxPayService wxPayService;
|
private final WxPayService wxPayService;
|
||||||
@ -76,25 +72,7 @@ public class WithdrawServiceImpl extends ServiceImpl<WithdrawMapper, Withdraw> 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,19 +91,12 @@ public class WithdrawServiceImpl extends ServiceImpl<WithdrawMapper, Withdraw> i
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//sender
|
|
||||||
//receiver
|
|
||||||
//object
|
|
||||||
//action
|
|
||||||
|
|
||||||
|
|
||||||
private InitiateBatchTransferRequestNew buildWechatPayParam(Withdraw withdraw) {
|
private InitiateBatchTransferRequestNew buildWechatPayParam(Withdraw withdraw) {
|
||||||
InitiateBatchTransferRequestNew request = new InitiateBatchTransferRequestNew();
|
InitiateBatchTransferRequestNew request = new InitiateBatchTransferRequestNew();
|
||||||
//商户AppID
|
//商户AppID
|
||||||
request.setAppid(wechatPayConfig.getAppId());
|
request.setAppid(wechatPayConfig.getAppId());
|
||||||
//商户单号
|
//商户单号
|
||||||
request.setOutBillNo(withdraw.getCode());
|
request.setOutBillNo(withdraw.getId()+"");
|
||||||
request.setTransferAmount(withdraw.getMoney().multiply(BigDecimal.valueOf(100)).intValue());
|
request.setTransferAmount(withdraw.getMoney().multiply(BigDecimal.valueOf(100)).intValue());
|
||||||
//转账场景ID
|
//转账场景ID
|
||||||
// /** 转账场景ID 说明:该批次转账使用的转账场景,如不填写则使用商家的默认场景,如无默认场景可为空,可前往“商家转账到零钱-前往功能”中申请。 如:1001-现金营销 */
|
// /** 转账场景ID 说明:该批次转账使用的转账场景,如不填写则使用商家的默认场景,如无默认场景可为空,可前往“商家转账到零钱-前往功能”中申请。 如:1001-现金营销 */
|
||||||
@ -160,26 +131,17 @@ public class WithdrawServiceImpl extends ServiceImpl<WithdrawMapper, Withdraw> i
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean withdrawCallback(WithdrawBO withdraw) {
|
public boolean withdrawCallback(Long id) {
|
||||||
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);
|
|
||||||
|
|
||||||
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,7 +153,9 @@ public class WithdrawServiceImpl extends ServiceImpl<WithdrawMapper, Withdraw> i
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
public boolean withdraw (Withdraw withdraw) {
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public InitiateBatchTransferResponseNew withdraw (Withdraw withdraw) {
|
||||||
MemberAccount memberAccount = memberAccountService.getMemberAccount(withdraw.getMemberId());
|
MemberAccount memberAccount = memberAccountService.getMemberAccount(withdraw.getMemberId());
|
||||||
if (memberAccount == null) {
|
if (memberAccount == null) {
|
||||||
throw new RuntimeException("用户不存在");
|
throw new RuntimeException("用户不存在");
|
||||||
@ -201,6 +165,16 @@ public class WithdrawServiceImpl extends ServiceImpl<WithdrawMapper, Withdraw> i
|
|||||||
if (balance==null||balance.compareTo(withdraw.getMoney()) < 0) {
|
if (balance==null||balance.compareTo(withdraw.getMoney()) < 0) {
|
||||||
throw new RuntimeException("用户余额不足");
|
throw new RuntimeException("用户余额不足");
|
||||||
}
|
}
|
||||||
|
//检查提现次数是否超过限制
|
||||||
|
long count = this.count(new LambdaQueryWrapper<Withdraw>()
|
||||||
|
.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"));
|
BigDecimal fee = withdraw.getMoney().multiply(new BigDecimal("0.00"));
|
||||||
withdraw.setFee(fee);
|
withdraw.setFee(fee);
|
||||||
@ -223,6 +197,23 @@ public class WithdrawServiceImpl extends ServiceImpl<WithdrawMapper, Withdraw> i
|
|||||||
.source(AccountBillSourceEnum.WITHDRAW.getCode())
|
.source(AccountBillSourceEnum.WITHDRAW.getCode())
|
||||||
.build();
|
.build();
|
||||||
accountBillService.save(memberAccountChangeRecord);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ package com.wzj.soopin.transaction.service.impl;
|
|||||||
import cn.hutool.core.io.IoUtil;
|
import cn.hutool.core.io.IoUtil;
|
||||||
import com.alibaba.fastjson.JSON;
|
import com.alibaba.fastjson.JSON;
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
import com.wechat.pay.java.core.Config;
|
import com.wechat.pay.java.core.Config;
|
||||||
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
|
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
|
||||||
import com.wechat.pay.java.core.cipher.PrivacyDecryptor;
|
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.domain.entity.TransferDetailEntityNew;
|
||||||
import com.wzj.soopin.transaction.wechat.WechatPayConfig;
|
import com.wzj.soopin.transaction.wechat.WechatPayConfig;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.dromara.common.core.constant.ResultCode;
|
|
||||||
import org.dromara.common.core.exception.ServiceException;
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@ -44,7 +44,7 @@ public class WxPayService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private WechatPayConfig WechatPayConfig;
|
private WechatPayConfig WechatPayConfig;
|
||||||
@Autowired
|
@Autowired
|
||||||
private RSAAutoCertificateConfig wxPayConfig;
|
private RSAAutoCertificateConfig config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商家转账 - 发起转账 - 2025年1月15号之后,商户转账零线必须用户确认收款
|
* 商家转账 - 发起转账 - 2025年1月15号之后,商户转账零线必须用户确认收款
|
||||||
@ -54,8 +54,8 @@ public class WxPayService {
|
|||||||
*/
|
*/
|
||||||
public InitiateBatchTransferResponseNew initiateBatchTransferNew(InitiateBatchTransferRequestNew request) {
|
public InitiateBatchTransferResponseNew initiateBatchTransferNew(InitiateBatchTransferRequestNew request) {
|
||||||
logger.info("WxPayService.initiateBatchTransferNew request:{}", request.toString());
|
logger.info("WxPayService.initiateBatchTransferNew request:{}", request.toString());
|
||||||
if(request.getUserName()!=null){
|
if(request.getUserName() != null && !request.getUserName().isEmpty()){
|
||||||
String encryptName = wxPayConfig.createEncryptor().encrypt(request.getUserName());
|
String encryptName = config.createEncryptor().encrypt(request.getUserName());
|
||||||
request.setUserName(encryptName);
|
request.setUserName(encryptName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ public class WxPayService {
|
|||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.addHeader("Accept", MediaType.APPLICATION_JSON.getValue());
|
headers.addHeader("Accept", MediaType.APPLICATION_JSON.getValue());
|
||||||
headers.addHeader("Content-Type", 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 =
|
HttpRequest httpRequest =
|
||||||
new HttpRequest.Builder()
|
new HttpRequest.Builder()
|
||||||
.httpMethod(HttpMethod.POST)
|
.httpMethod(HttpMethod.POST)
|
||||||
@ -71,7 +71,38 @@ public class WxPayService {
|
|||||||
.headers(headers)
|
.headers(headers)
|
||||||
.body(createRequestBody(request))
|
.body(createRequestBody(request))
|
||||||
.build();
|
.build();
|
||||||
HttpClient httpClient = new DefaultHttpClientBuilder().config(wxPayConfig).build();
|
HttpClient httpClient = new DefaultHttpClientBuilder().config(config).build();
|
||||||
|
HttpResponse<InitiateBatchTransferResponseNew> 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<InitiateBatchTransferResponseNew> httpResponse = httpClient.execute(httpRequest, InitiateBatchTransferResponseNew.class);
|
HttpResponse<InitiateBatchTransferResponseNew> httpResponse = httpClient.execute(httpRequest, InitiateBatchTransferResponseNew.class);
|
||||||
logger.info("WxPayService.initiateBatchTransferNew response:{}", httpResponse.getServiceResponse());
|
logger.info("WxPayService.initiateBatchTransferNew response:{}", httpResponse.getServiceResponse());
|
||||||
return httpResponse.getServiceResponse();
|
return httpResponse.getServiceResponse();
|
||||||
@ -85,13 +116,6 @@ public class WxPayService {
|
|||||||
*/
|
*/
|
||||||
public TransferDetailEntityNew getTransferDetailByOutNoNew(String outBillNo) {
|
public TransferDetailEntityNew getTransferDetailByOutNoNew(String outBillNo) {
|
||||||
logger.info("WxPayService.getTransferDetailByOutNoNew request:{}", 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}";
|
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));
|
requestPath = requestPath.replace("{out_bill_no}", UrlEncoder.urlEncode(outBillNo));
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@ -117,7 +141,7 @@ public class WxPayService {
|
|||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
private static RequestBody createRequestBody(Object request) {
|
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)
|
.body(requestBody)
|
||||||
.build();
|
.build();
|
||||||
// 2. 构建Config RSAAutoCertificateConfig
|
// 2. 构建Config RSAAutoCertificateConfig
|
||||||
Config config = new RSAAutoCertificateConfig.Builder()
|
logger.info("WxPayService.wxPaySuccessCallback request : wechatPaySerial is [{}] , wechatSignature is [{}] , wechatTimestamp is [{}] , wechatpayNonce is [{}] , requestBody is [{}]",wechatPaySerial,wechatSignature,wechatTimestamp,wechatpayNonce,requestBody);
|
||||||
.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);
|
|
||||||
// 3. 初始化 NotificationParser
|
// 3. 初始化 NotificationParser
|
||||||
NotificationParser parser = new NotificationParser((NotificationConfig) config);
|
NotificationParser parser = new NotificationParser( config);
|
||||||
try {
|
try {
|
||||||
TransferDetailEntityNew entity = parser.parse(requestParam, TransferDetailEntityNew.class);
|
TransferDetailEntityNew entity = parser.parse(requestParam, TransferDetailEntityNew.class);
|
||||||
logger.info("WxPayService.wxPaySuccessCallback responseBody: {}", entity != null ? JSON.toJSONString(entity) : null);
|
logger.info("WxPayService.wxPaySuccessCallback responseBody: {}", entity != null ? JSON.toJSONString(entity) : null);
|
||||||
@ -240,5 +258,6 @@ public class WxPayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException {
|
||||||
|
return gson.fromJson(json, classOfT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从公私钥文件路径中读取文件内容
|
||||||
|
*
|
||||||
|
* @param keyPath 文件路径
|
||||||
|
* @return 文件内容
|
||||||
|
*/
|
||||||
|
private static String readKeyStringFromPath(String keyPath) {
|
||||||
|
try {
|
||||||
|
return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 PKCS#8 格式的私钥字符串并加载为私钥对象
|
||||||
|
*
|
||||||
|
* @param keyString 私钥文件内容,以 -----BEGIN PRIVATE KEY----- 开头
|
||||||
|
* @return PrivateKey 对象
|
||||||
|
*/
|
||||||
|
public static PrivateKey loadPrivateKeyFromString(String keyString) {
|
||||||
|
try {
|
||||||
|
keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||||
|
.replace("-----END PRIVATE KEY-----", "")
|
||||||
|
.replaceAll("\\s+", "");
|
||||||
|
return KeyFactory.getInstance("RSA").generatePrivate(
|
||||||
|
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new UnsupportedOperationException(e);
|
||||||
|
} catch (InvalidKeySpecException e) {
|
||||||
|
throw new IllegalArgumentException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 PKCS#8 格式的私钥文件中加载私钥
|
||||||
|
*
|
||||||
|
* @param keyPath 私钥文件路径
|
||||||
|
* @return PrivateKey 对象
|
||||||
|
*/
|
||||||
|
public static PrivateKey loadPrivateKeyFromPath(String keyPath) {
|
||||||
|
return loadPrivateKeyFromString(readKeyStringFromPath(keyPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 PKCS#8 格式的公钥字符串并加载为公钥对象
|
||||||
|
*
|
||||||
|
* @param keyString 公钥文件内容,以 -----BEGIN PUBLIC KEY----- 开头
|
||||||
|
* @return PublicKey 对象
|
||||||
|
*/
|
||||||
|
public static PublicKey loadPublicKeyFromString(String keyString) {
|
||||||
|
try {
|
||||||
|
keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||||
|
.replace("-----END PUBLIC KEY-----", "")
|
||||||
|
.replaceAll("\\s+", "");
|
||||||
|
return KeyFactory.getInstance("RSA").generatePublic(
|
||||||
|
new X509EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new UnsupportedOperationException(e);
|
||||||
|
} catch (InvalidKeySpecException e) {
|
||||||
|
throw new IllegalArgumentException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 PKCS#8 格式的公钥文件中加载公钥
|
||||||
|
*
|
||||||
|
* @param keyPath 公钥文件路径
|
||||||
|
* @return PublicKey 对象
|
||||||
|
*/
|
||||||
|
public static PublicKey loadPublicKeyFromPath(String keyPath) {
|
||||||
|
return loadPublicKeyFromString(readKeyStringFromPath(keyPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建指定长度的随机字符串,字符集为[0-9a-zA-Z],可用于安全相关用途
|
||||||
|
*/
|
||||||
|
public static String createNonce(int length) {
|
||||||
|
char[] buf = new char[length];
|
||||||
|
for (int i = 0; i < length; ++i) {
|
||||||
|
buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)];
|
||||||
|
}
|
||||||
|
return new String(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用公钥按照 RSA_PKCS1_OAEP_PADDING 算法进行加密
|
||||||
|
*
|
||||||
|
* @param publicKey 加密用公钥对象
|
||||||
|
* @param plaintext 待加密明文
|
||||||
|
* @return 加密后密文
|
||||||
|
*/
|
||||||
|
public static String encrypt(PublicKey publicKey, String plaintext) {
|
||||||
|
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||||
|
|
||||||
|
try {
|
||||||
|
Cipher cipher = Cipher.getInstance(transformation);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||||
|
return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||||
|
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||||
|
} catch (InvalidKeyException e) {
|
||||||
|
throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e);
|
||||||
|
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||||
|
throw new IllegalArgumentException("Plaintext is too long", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, Object> params) {
|
||||||
|
if (params == null || params.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
boolean isFirstParam = true;
|
||||||
|
for (Map.Entry<String, Object> 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<String, String> 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");
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user