6.28 动态配置活动降级拦截及用户切量拦截

This commit is contained in:
zhangsan 2025-06-28 17:40:09 +08:00
parent 8ff266ff41
commit aa1002d74c
12 changed files with 379 additions and 4 deletions

View File

@ -0,0 +1,12 @@
package edu.whut.api;
import edu.whut.api.response.Response;
/**
* DCC 动态配置中心
*/
public interface IDCCService {
Response<Boolean> updateConfig(String key, String value);
}

View File

@ -0,0 +1,160 @@
package edu.whut.config;
import edu.whut.types.annotations.DCCValue;
import edu.whut.types.common.Constants;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.redisson.api.RBucket;
import org.redisson.api.RTopic;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
/**
* Dynamic Config CenterDCC 基于 RedisRedisson实现的轻量级配置中心
* 作用
* 1. Bean 初始化阶段扫描带 {@link DCCValue} 注解的字段将默认值写入 Redis 并注入到 Bean
* 2. 通过 Redis Topic 监听配置变更事件实现运行期热更新
*/
@Slf4j
@Configuration
public class DCCValueBeanFactory implements BeanPostProcessor {
/** Redis 中所有 DCC Key 的统一前缀 */
private static final String BASE_CONFIG_PATH = "group_buy_market_dcc_";
private final RedissonClient redissonClient;
/** 记录「配置 Key → 注入了该 Key 的 Bean 实例」的映射,用于收到变更后反射刷新字段值 */
private final Map<String, Object> dccObjGroup = new HashMap<>();
/**
* 通过构造器注入 RedissonClient便于单元测试 Mock
* @param redissonClient Spring 上下文提供的 RedissonClient
*/
public DCCValueBeanFactory(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
/**
* 定义一个 {@link RTopic} Bean 并注册监听器用于接收
* 其他节点发布的配置变更消息
* 消息格式attribute:value例如 cutRange:30
* @param redissonClient 容器注入的 RedissonClient
* @return 配置变更 Topic 对象Bean 名称为 dccTopic
*/
@Bean("dccTopic")
public RTopic dccRedisTopicListener(RedissonClient redissonClient) {
RTopic topic = redissonClient.getTopic("group_buy_market_dcc");
topic.addListener(String.class, (channel, message) -> {
// message 示例 "cutRange:30"
String[] split = message.split(Constants.SPLIT);
String attribute = split[0]; // 字段名
String value = split[1]; // 新值
String key = BASE_CONFIG_PATH + attribute;
// 1. 写回 Redis保证一致性
RBucket<String> bucket = redissonClient.getBucket(key);
if (!bucket.isExists()) { return; }
bucket.set(value);
// 2. 本地内存刷新反射写回所有注入了该字段的 Bean
Object objBean = dccObjGroup.get(key);
if (objBean == null) { return; }
Class<?> objBeanClass = objBean.getClass();
// 兼容 AOP 代理场景取真实目标类
if (AopUtils.isAopProxy(objBean)) {
objBeanClass = AopUtils.getTargetClass(objBean);
}
try {
Field field = objBeanClass.getDeclaredField(attribute);
field.setAccessible(true);
field.set(objBean, value);
field.setAccessible(false);
log.info("DCC → 热更新成功:{} = {}", key, value);
} catch (Exception e) {
throw new RuntimeException("DCC 反射刷新失败:" + key, e);
}
});
return topic;
}
/**
* Spring 容器在启动并创建每个 Bean 的时候都会依次回调postProcessAfterInitialization 方法
* 扫描所有带 {@link DCCValue} 注解的字段
* 把默认值写入 Redis若不存在
* Redis 中最新值注入到字段
* 记录key Bean 实例映射以便后续热更新
*
* @param bean 当前实例化完成的 Bean
* @param beanName Spring 为该 Bean 生成的名称
* @return 可能被改写后的 Bean此处直接返回原 Bean
* @throws BeansException Spring 容器异常
*/
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
// 处理 AOP 代理拿到目标类 & 真实对象
//默认假设 bean 就是我们想要的目标类
Class<?> targetBeanClass = bean.getClass();
Object targetBeanObj = bean;
//如果它是一个 AOP 代理对象剥掉代理找到真实的类和实例
if (AopUtils.isAopProxy(bean)) {
targetBeanClass = AopUtils.getTargetClass(bean);
targetBeanObj = AopProxyUtils.getSingletonTarget(bean);
}
// 遍历字段
for (Field field : targetBeanClass.getDeclaredFields()) {
//寻找带 @DCCValue 的字段
if (!field.isAnnotationPresent(DCCValue.class)) { continue; }
DCCValue dccValue = field.getAnnotation(DCCValue.class);
String rawValue = dccValue.value(); // eg:"downgradeSwitch:0"
if (StringUtils.isBlank(rawValue)) {
throw new RuntimeException(field.getName() + " 缺少 @DCCValue 默认值");
}
String[] splits = rawValue.split(":");
String key = BASE_CONFIG_PATH + splits[0];
String defaultVal = splits.length == 2 ? splits[1] : null;
if (StringUtils.isBlank(defaultVal)) {
throw new RuntimeException("DCC Key " + key + " 未配置默认值");
}
// 1. Redis 同步若不存在则写默认值若已存在则取最新值
RBucket<String> bucket = redissonClient.getBucket(key);
String injectedValue = bucket.isExists() ? bucket.get() : defaultVal;
if (!bucket.isExists()) { bucket.set(defaultVal); }
// 2. 反射注入字段
try {
field.setAccessible(true);
field.set(targetBeanObj, injectedValue);
field.setAccessible(false);
} catch (IllegalAccessException e) {
throw new RuntimeException("DCC 注入字段失败:" + key, e);
}
// 3. 记录key Bean Topic 监听时热更新
dccObjGroup.put(key, targetBeanObj);
}
return bean;
}
}

View File

@ -0,0 +1,55 @@
package edu.whut.test.trigger;
import com.alibaba.fastjson.JSON;
import edu.whut.api.IDCCService;
import edu.whut.domain.activity.model.entity.MarketProductEntity;
import edu.whut.domain.activity.model.entity.TrialBalanceEntity;
import edu.whut.domain.activity.service.IIndexGroupBuyMarketService;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import javax.annotation.Resource;
/**
* 动态配置管理测试
*/
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class DCCControllerTest {
@Resource
private IDCCService dccService;
@Resource
private IIndexGroupBuyMarketService indexGroupBuyMarketService;
@Test
public void test_updateConfig() {
// 动态调整配置
dccService.updateConfig("downgradeSwitch", "1");
}
@Test
public void test_updateConfig2indexMarketTrial() throws Exception {
// 动态调整配置
dccService.updateConfig("downgradeSwitch", "1");
// 超时等待异步
Thread.sleep(1000);
// 营销验证
MarketProductEntity marketProductEntity = new MarketProductEntity();
marketProductEntity.setUserId("smile");
marketProductEntity.setSource("s01");
marketProductEntity.setChannel("c01");
marketProductEntity.setGoodsId("9890001");
TrialBalanceEntity trialBalanceEntity = indexGroupBuyMarketService.indexMarketTrial(marketProductEntity);
log.info("请求参数:{}", JSON.toJSONString(marketProductEntity));
log.info("返回结果:{}", JSON.toJSONString(trialBalanceEntity));
}
}

View File

@ -16,4 +16,8 @@ public interface IActivityRepository {
boolean isTagCrowdRange(String tagId, String userId);
boolean downgradeSwitch();
boolean cutRange(String userId);
}

View File

@ -31,8 +31,6 @@ public class MarketNode extends AbstractGroupBuyMarketSupport<MarketProductEntit
private final ThreadPoolExecutor threadPoolExecutor;
private final EndNode endNode;
private final ErrorNode errorNode;
private final Map<String, IDiscountCalculateService> discountCalculateServiceMap;

View File

@ -1,9 +1,12 @@
package edu.whut.domain.activity.service.trial.node;
import com.alibaba.fastjson.JSON;
import edu.whut.domain.activity.model.entity.MarketProductEntity;
import edu.whut.domain.activity.model.entity.TrialBalanceEntity;
import edu.whut.domain.activity.service.trial.AbstractGroupBuyMarketSupport;
import edu.whut.domain.activity.service.trial.factory.DefaultActivityStrategyFactory;
import edu.whut.types.design.framework.tree.StrategyHandler;
import edu.whut.types.enums.ResponseCode;
import edu.whut.types.exception.AppException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@ -20,6 +23,22 @@ public class SwitchNode extends AbstractGroupBuyMarketSupport<MarketProductEntit
@Override
public TrialBalanceEntity doApply(MarketProductEntity requestParameter, DefaultActivityStrategyFactory.DynamicContext dynamicContext) throws Exception {
log.info("拼团商品查询试算服务-SwitchNode userId:{} requestParameter:{}", requestParameter.getUserId(), JSON.toJSONString(requestParameter));
// 根据用户ID切量
String userId = requestParameter.getUserId();
// 判断是否降级
if (repository.downgradeSwitch()) {
log.info("拼团活动降级拦截 {}", userId);
throw new AppException(ResponseCode.E0003.getCode(), ResponseCode.E0003.getInfo());
}
// 切量范围判断
if (!repository.cutRange(userId)) {
log.info("拼团活动切量拦截 {}", userId);
throw new AppException(ResponseCode.E0004.getCode(), ResponseCode.E0004.getInfo());
}
return router(requestParameter, dynamicContext);
}

View File

@ -9,6 +9,7 @@ import edu.whut.infrastructure.dao.IGroupBuyActivityDao;
import edu.whut.infrastructure.dao.IGroupBuyDiscountDao;
import edu.whut.infrastructure.dao.ISCSkuActivityDao;
import edu.whut.infrastructure.dao.ISkuDao;
import edu.whut.infrastructure.dao.dcc.DCCService;
import edu.whut.infrastructure.dao.po.GroupBuyActivity;
import edu.whut.infrastructure.dao.po.GroupBuyDiscount;
import edu.whut.infrastructure.dao.po.SCSkuActivity;
@ -19,7 +20,6 @@ import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBitSet;
import org.springframework.stereotype.Repository;
import javax.annotation.Resource;
/**
* 活动仓储
@ -39,6 +39,8 @@ public class ActivityRepository implements IActivityRepository {
private final IRedisService redisService;
private final DCCService dccService;
@Override
public GroupBuyActivityDiscountVO queryGroupBuyActivityDiscountVO(Long activityId) {
GroupBuyActivity groupBuyActivityRes = groupBuyActivityDao.queryValidGroupBuyActivityId(activityId);
@ -114,4 +116,14 @@ public class ActivityRepository implements IActivityRepository {
return bitSet.get(redisService.getIndexFromUserId(userId));
}
@Override
public boolean downgradeSwitch() {
return dccService.isDowngradeSwitch();
}
@Override
public boolean cutRange(String userId) {
return dccService.isCutRange(userId);
}
}

View File

@ -0,0 +1,46 @@
package edu.whut.infrastructure.dao.dcc;
import edu.whut.types.annotations.DCCValue;
import org.springframework.stereotype.Service;
/**
* DCC动态配置服务 Dynamic Configuration Center
*/
@Service
public class DCCService {
/**
* 降级开关 0关闭1开启 当外部依赖异常系统负载过高等场景下主动关闭或简化某些功能
*/
@DCCValue("downgradeSwitch:0")
private String downgradeSwitch;
/**
* 人群切量开关只让部分人群先使用新功能或新版本
*/
@DCCValue("cutRange:100")
private String cutRange;
/**
* 判断是否降级
* @return
*/
public boolean isDowngradeSwitch() {
return "1".equals(downgradeSwitch);
}
public boolean isCutRange(String userId) {
// 计算哈希码的绝对值
int hashCode = Math.abs(userId.hashCode());
// 获取最后两位
int lastTwoDigits = hashCode % 100;
// 判断是否在切量范围内
if (lastTwoDigits <= Integer.parseInt(cutRange)) {
return true;
}
return false;
}
}

View File

@ -26,7 +26,10 @@
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
</dependency>
<!-- 系统模块 -->
<dependency>
<groupId>edu.whut</groupId>

View File

@ -0,0 +1,49 @@
package edu.whut.trigger.http;
import edu.whut.api.IDCCService;
import edu.whut.api.response.Response;
import edu.whut.types.enums.ResponseCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RTopic;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
/**
* 动态配置管理
*/
@Slf4j
@RestController()
@CrossOrigin("*")
@RequestMapping("/api/v1/gbm/dcc/")
@RequiredArgsConstructor
public class DCCController implements IDCCService {
private final RTopic dccTopic;
/**
* 动态值变更
* curl http://localhost:8091/api/v1/gbm/dcc/update_config?key=downgradeSwitch&value=1
* curl http://localhost:8091/api/v1/gbm/dcc/update_config?key=cutRange&value=0
*/
@GetMapping("update_config")
@Override
public Response<Boolean> updateConfig(@RequestParam String key,
@RequestParam String value) {
try {
log.info("DCC 动态配置值变更 key:{} value:{}", key, value);
dccTopic.publish(key + "," + value);
return Response.<Boolean>builder()
.code(ResponseCode.SUCCESS.getCode())
.info(ResponseCode.SUCCESS.getInfo())
.build();
} catch (Exception e) {
log.error("DCC 动态配置值变更失败 key:{} value:{}", key, value, e);
return Response.<Boolean>builder()
.code(ResponseCode.UN_ERROR.getCode())
.info(ResponseCode.UN_ERROR.getInfo())
.build();
}
}
}

View File

@ -0,0 +1,15 @@
package edu.whut.types.annotations;
import java.lang.annotation.*;
/**
* 注解动态配置中心标记
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
@Documented
public @interface DCCValue {
String value() default "";
}

View File

@ -14,6 +14,8 @@ public enum ResponseCode {
ILLEGAL_PARAMETER("0002", "非法参数"),
E0001("E0001", "不存在对应的折扣计算服务"),
E0002("E0002", "无拼团营销配置"),
E0003("E0003", "拼团活动降级拦截"),
E0004("E0004", "拼团活动切量拦截"),
;
private String code;