接口加密,分销
This commit is contained in:
1 parent
30176e7268
commit
c52bbc1a2c
15 files changed
+347
-19
No files matched your search
+9
@@ -0,0 +1,9 @@
|
||||
package cn.iocoder.yudao.module.member.framework.crypto;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(RespBodyEncryptProperties.class)
|
||||
public class RespBodyEncryptConfiguration {
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package cn.iocoder.yudao.module.member.framework.crypto;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* App 敏感接口响应体加密配置
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "yudao.member.resp-encrypt")
|
||||
@Data
|
||||
public class RespBodyEncryptProperties {
|
||||
|
||||
/**
|
||||
* 是否开启(总开关)
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* RSA 私钥(PKCS#8 Base64,无 PEM 头尾)
|
||||
*/
|
||||
private String privateKeyBase64;
|
||||
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package cn.iocoder.yudao.module.member.framework.crypto;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 对白名单接口的 CommonResult.data 做响应加密(需客户端 Header 声明)。
|
||||
*/
|
||||
@ControllerAdvice
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(prefix = "yudao.member.resp-encrypt", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class RespBodyEncryptResponseAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
private final RespBodyEncryptProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private RespBodyEncryptSupport support;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
if (StrUtil.isBlank(properties.getPrivateKeyBase64())) {
|
||||
log.warn("[resp-encrypt] 未配置 yudao.member.resp-encrypt.private-key-base64,响应加密不可用");
|
||||
support = null;
|
||||
return;
|
||||
}
|
||||
support = new RespBodyEncryptSupport(properties.getPrivateKeyBase64(), objectMapper);
|
||||
log.info("[resp-encrypt] 响应加密已启用,白名单={}", RespBodyEncryptSupport.PATH_SUFFIXES);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType, Class converterType) {
|
||||
return support != null
|
||||
&& returnType.getMethod() != null
|
||||
&& returnType.getMethod().getReturnType() == CommonResult.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
|
||||
Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
|
||||
if (!(body instanceof CommonResult) || support == null) {
|
||||
return body;
|
||||
}
|
||||
CommonResult<Object> result = (CommonResult<Object>) body;
|
||||
if (!result.isSuccess() || result.getData() == null) {
|
||||
return body;
|
||||
}
|
||||
|
||||
String path = request.getURI().getPath();
|
||||
if (!RespBodyEncryptSupport.matchPath(path)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
List<String> encHeaders = request.getHeaders().get(RespBodyEncryptSupport.HEADER_RESP_ENC);
|
||||
String enc = (encHeaders != null && !encHeaders.isEmpty()) ? encHeaders.get(0) : null;
|
||||
if (!RespBodyEncryptSupport.wantEncrypt(enc)) {
|
||||
// 旧客户端未声明加密:仍返回明文,便于过渡
|
||||
return body;
|
||||
}
|
||||
|
||||
List<String> ekHeaders = request.getHeaders().get(RespBodyEncryptSupport.HEADER_RESP_EK);
|
||||
String ek = (ekHeaders != null && !ekHeaders.isEmpty()) ? ekHeaders.get(0) : null;
|
||||
try {
|
||||
result.setData(support.encryptData(result.getData(), ek));
|
||||
} catch (Exception e) {
|
||||
log.error("[resp-encrypt] 加密失败 path={} err={}", path, e.getMessage());
|
||||
return CommonResult.error(1_004_000_101, "响应加密失败,请升级客户端后重试");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package cn.iocoder.yudao.module.member.framework.crypto;
|
||||
|
||||
import cn.hutool.core.codec.Base64;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 响应体加密:客户端用 RSA 公钥加密随机 AES Key(Header),服务端 AES-GCM 加密 data。
|
||||
*/
|
||||
@Slf4j
|
||||
public class RespBodyEncryptSupport {
|
||||
|
||||
public static final String HEADER_RESP_ENC = "X-Resp-Enc";
|
||||
public static final String HEADER_RESP_EK = "X-Resp-Ek";
|
||||
|
||||
public static final Set<String> PATH_SUFFIXES = new HashSet<>(Arrays.asList(
|
||||
"/member/recent/quick-page",
|
||||
"/member/recent/getById",
|
||||
"/member/recent/look-wechat"
|
||||
));
|
||||
|
||||
private static final String RSA_TRANSFORMATION = "RSA/ECB/PKCS1Padding";
|
||||
private static final String AES_TRANSFORMATION = "AES/GCM/NoPadding";
|
||||
private static final int GCM_TAG_BITS = 128;
|
||||
private static final int AES_KEY_BITS = 256;
|
||||
private static final int IV_LEN = 12;
|
||||
|
||||
private final PrivateKey privateKey;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public RespBodyEncryptSupport(String privateKeyBase64, ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
try {
|
||||
byte[] keyBytes = Base64.decode(privateKeyBase64);
|
||||
this.privateKey = KeyFactory.getInstance("RSA")
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("初始化响应加密 RSA 私钥失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean matchPath(String path) {
|
||||
if (StrUtil.isBlank(path)) {
|
||||
return false;
|
||||
}
|
||||
for (String suffix : PATH_SUFFIXES) {
|
||||
if (path.endsWith(suffix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean wantEncrypt(String encHeader) {
|
||||
return "1".equals(encHeader) || "true".equalsIgnoreCase(encHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 Header 中的 RSA 密文解出 AES Key,再加密 data 为信封 Map。
|
||||
*/
|
||||
public Map<String, Object> encryptData(Object data, String ekBase64) throws Exception {
|
||||
if (StrUtil.isBlank(ekBase64)) {
|
||||
throw new IllegalArgumentException("缺少 X-Resp-Ek");
|
||||
}
|
||||
byte[] aesKey = decryptAesKey(ekBase64);
|
||||
byte[] plain = objectMapper.writeValueAsBytes(data == null ? new HashMap<>() : data);
|
||||
|
||||
byte[] iv = new byte[IV_LEN];
|
||||
secureRandom.nextBytes(iv);
|
||||
|
||||
Cipher aes = Cipher.getInstance(AES_TRANSFORMATION);
|
||||
aes.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||
byte[] ct = aes.doFinal(plain);
|
||||
|
||||
Map<String, Object> envelope = new HashMap<>(8);
|
||||
envelope.put("enc", 1);
|
||||
envelope.put("v", 1);
|
||||
envelope.put("alg", "RSA-PKCS1-AES-256-GCM");
|
||||
envelope.put("iv", Base64.encode(iv));
|
||||
envelope.put("ct", Base64.encode(ct));
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private byte[] decryptAesKey(String ekBase64) throws Exception {
|
||||
Cipher rsa = Cipher.getInstance(RSA_TRANSFORMATION);
|
||||
rsa.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
byte[] key = rsa.doFinal(Base64.decode(ekBase64));
|
||||
if (key.length != 16 && key.length != 24 && key.length != 32) {
|
||||
throw new IllegalArgumentException("AES Key 长度非法: " + key.length);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/** 仅测试/工具用 */
|
||||
public static byte[] generateAesKeyBytes() throws Exception {
|
||||
KeyGenerator kg = KeyGenerator.getInstance("AES");
|
||||
kg.init(AES_KEY_BITS);
|
||||
SecretKey key = kg.generateKey();
|
||||
return key.getEncoded();
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -33,6 +33,12 @@ import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO;
|
||||
import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO;
|
||||
import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
|
||||
import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO;
|
||||
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO;
|
||||
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
@@ -60,6 +66,7 @@ import static cn.hutool.core.util.ObjectUtil.notEqual;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.addTime;
|
||||
import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString;
|
||||
import static cn.iocoder.yudao.framework.common.util.number.MoneyUtils.fenToYuanStr;
|
||||
import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
|
||||
import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum.*;
|
||||
@@ -90,6 +97,8 @@ public class OpenTypeServiceImpl implements OpenTypeService {
|
||||
private PayOrderApi payOrderApi;
|
||||
@Resource
|
||||
private PayRefundApi payRefundApi;
|
||||
@Resource
|
||||
private TradeBrokerageRecordApi tradeBrokerageRecordApi;
|
||||
|
||||
/**
|
||||
* 获取最后一条会员开通记录
|
||||
@@ -423,10 +432,36 @@ public class OpenTypeServiceImpl implements OpenTypeService {
|
||||
// 添加会员tag
|
||||
memberRecentTagService.createOrUpdateVipTag(openTypeUserDO.getMemberId());
|
||||
|
||||
// 添加会员开通分销佣金(每次开通都分,对应 OPEN_TYPE)
|
||||
addOpenTypeBrokerage(openTypeUserDO, openType, payOrder);
|
||||
|
||||
String message = String.format("恭喜您购买成功,此次购买的产品:%s", openType.getTitle());
|
||||
this.chatApi.messageUserSendText(openTypeUserDO.getMemberId(), message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员开通支付成功后,给上级增加分销佣金
|
||||
*/
|
||||
private void addOpenTypeBrokerage(OpenTypeUserDO openTypeUserDO, OpenTypeDO openType, PayOrderRespDTO payOrder) {
|
||||
Integer basePrice = payOrder.getPrice();
|
||||
if (basePrice == null || basePrice <= 0) {
|
||||
log.info("[会员开通分佣][开通单({}) 支付金额为0,跳过分佣]", openTypeUserDO.getId());
|
||||
return;
|
||||
}
|
||||
MemberUserDO user = memberUserService.getUser(openTypeUserDO.getMemberId());
|
||||
String nickname = user != null && user.getNickname() != null ? user.getNickname() : String.valueOf(openTypeUserDO.getMemberId());
|
||||
String productTitle = openType != null && openType.getTitle() != null ? openType.getTitle() : "会员";
|
||||
List<BrokerageAddDTO> brokerageList = new ArrayList<>();
|
||||
brokerageList.add(new BrokerageAddDTO()
|
||||
.setBizId(String.valueOf(openTypeUserDO.getId()))
|
||||
.setBasePrice(basePrice)
|
||||
.setFirstFixedPrice(0)
|
||||
.setSecondFixedPrice(0)
|
||||
.setSourceUserId(openTypeUserDO.getMemberId())
|
||||
.setTitle(String.format("用户[%s]开通会员[%s],支付[%s]元", nickname, productTitle, fenToYuanStr(basePrice))));
|
||||
tradeBrokerageRecordApi.addBrokerage(openTypeUserDO.getMemberId(), BrokerageRecordBizTypeEnum.OPEN_TYPE, brokerageList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款的回调
|
||||
*/
|
||||
@@ -445,6 +480,8 @@ public class OpenTypeServiceImpl implements OpenTypeService {
|
||||
updateObj.setRefundStatus(SUCCESS.getStatus())
|
||||
.setRefundTime(payRefund.getSuccessTime())
|
||||
.setRefundPayPrice(openTypeUserDO.getPayPrice());
|
||||
// 退款成功后作废对应会员佣金
|
||||
tradeBrokerageRecordApi.cancelBrokerage(BrokerageRecordBizTypeEnum.OPEN_TYPE, String.valueOf(id));
|
||||
}
|
||||
// 退款失败
|
||||
if (PayRefundStatusEnum.isFailure(payRefund.getStatus())) {
|
||||
@@ -452,7 +489,7 @@ public class OpenTypeServiceImpl implements OpenTypeService {
|
||||
}
|
||||
// 更新会员开通的退款状态
|
||||
openTypeUserMapper.update(updateObj, new LambdaQueryWrapperX<OpenTypeUserDO>()
|
||||
.eq(OpenTypeUserDO::getId, openTypeUserDO.getOpenTypeId())
|
||||
.eq(OpenTypeUserDO::getId, id)
|
||||
.eq(OpenTypeUserDO::getRefundStatus, WAITING.getStatus())
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user