diff --git a/group-buying-sys-api/src/main/java/edu/whut/api/IDCCService.java b/group-buying-sys-api/src/main/java/edu/whut/api/IDCCService.java new file mode 100644 index 0000000..e09100d --- /dev/null +++ b/group-buying-sys-api/src/main/java/edu/whut/api/IDCCService.java @@ -0,0 +1,12 @@ +package edu.whut.api; + +import edu.whut.api.response.Response; + +/** + * DCC 动态配置中心 + */ +public interface IDCCService { + + Response updateConfig(String key, String value); + +} diff --git a/group-buying-sys-app/src/main/java/edu/whut/config/DCCValueBeanFactory.java b/group-buying-sys-app/src/main/java/edu/whut/config/DCCValueBeanFactory.java new file mode 100644 index 0000000..15f5512 --- /dev/null +++ b/group-buying-sys-app/src/main/java/edu/whut/config/DCCValueBeanFactory.java @@ -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 Center(DCC)—— 基于 Redis(Redisson)实现的轻量级配置中心。 + * 作用: + * 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 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 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 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; + } + +} diff --git a/group-buying-sys-app/src/test/java/edu/whut/test/trigger/DCCControllerTest.java b/group-buying-sys-app/src/test/java/edu/whut/test/trigger/DCCControllerTest.java new file mode 100644 index 0000000..b1aa1d8 --- /dev/null +++ b/group-buying-sys-app/src/test/java/edu/whut/test/trigger/DCCControllerTest.java @@ -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)); + } + + +} diff --git a/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/adapter/repository/IActivityRepository.java b/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/adapter/repository/IActivityRepository.java index 6f10491..afa0663 100644 --- a/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/adapter/repository/IActivityRepository.java +++ b/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/adapter/repository/IActivityRepository.java @@ -16,4 +16,8 @@ public interface IActivityRepository { boolean isTagCrowdRange(String tagId, String userId); + boolean downgradeSwitch(); + + boolean cutRange(String userId); + } diff --git a/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/service/trial/node/MarketNode.java b/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/service/trial/node/MarketNode.java index 716f6ec..5aba850 100644 --- a/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/service/trial/node/MarketNode.java +++ b/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/service/trial/node/MarketNode.java @@ -31,8 +31,6 @@ public class MarketNode extends AbstractGroupBuyMarketSupport discountCalculateServiceMap; diff --git a/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/service/trial/node/SwitchNode.java b/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/service/trial/node/SwitchNode.java index cd737da..2d4e59d 100644 --- a/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/service/trial/node/SwitchNode.java +++ b/group-buying-sys-domain/src/main/java/edu/whut/domain/activity/service/trial/node/SwitchNode.java @@ -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 AbstractGroupBuyMarketSupportorg.apache.commons commons-lang3 - + + org.redisson + redisson-spring-boot-starter + edu.whut diff --git a/group-buying-sys-trigger/src/main/java/edu/whut/trigger/http/DCCController.java b/group-buying-sys-trigger/src/main/java/edu/whut/trigger/http/DCCController.java new file mode 100644 index 0000000..c130f62 --- /dev/null +++ b/group-buying-sys-trigger/src/main/java/edu/whut/trigger/http/DCCController.java @@ -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 updateConfig(@RequestParam String key, + @RequestParam String value) { + try { + log.info("DCC 动态配置值变更 key:{} value:{}", key, value); + dccTopic.publish(key + "," + value); + return Response.builder() + .code(ResponseCode.SUCCESS.getCode()) + .info(ResponseCode.SUCCESS.getInfo()) + .build(); + } catch (Exception e) { + log.error("DCC 动态配置值变更失败 key:{} value:{}", key, value, e); + return Response.builder() + .code(ResponseCode.UN_ERROR.getCode()) + .info(ResponseCode.UN_ERROR.getInfo()) + .build(); + } + } + +} diff --git a/group-buying-sys-types/src/main/java/edu/whut/types/annotations/DCCValue.java b/group-buying-sys-types/src/main/java/edu/whut/types/annotations/DCCValue.java new file mode 100644 index 0000000..e0cbc5c --- /dev/null +++ b/group-buying-sys-types/src/main/java/edu/whut/types/annotations/DCCValue.java @@ -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 ""; + +} diff --git a/group-buying-sys-types/src/main/java/edu/whut/types/enums/ResponseCode.java b/group-buying-sys-types/src/main/java/edu/whut/types/enums/ResponseCode.java index 237a9fe..1e4ea55 100644 --- a/group-buying-sys-types/src/main/java/edu/whut/types/enums/ResponseCode.java +++ b/group-buying-sys-types/src/main/java/edu/whut/types/enums/ResponseCode.java @@ -14,6 +14,8 @@ public enum ResponseCode { ILLEGAL_PARAMETER("0002", "非法参数"), E0001("E0001", "不存在对应的折扣计算服务"), E0002("E0002", "无拼团营销配置"), + E0003("E0003", "拼团活动降级拦截"), + E0004("E0004", "拼团活动切量拦截"), ; private String code;