接口加密,分销

This commit is contained in:
周鹏 committed 2026-08-11 20:16:48 +08:00
1 parent 30176e7268
commit c52bbc1a2c
15 files changed
+347 -19

No files matched your search

@@ -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 {
}
@@ -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;
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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())
);
}