Commit e198d988b1243da85c349d9d43b0d1600d88d2f3
Merge branch 'master' of gitee.com:zhijiantianya/ruoyi-vue-pro into feature/ut-auth
Showing
17 changed files
with
498 additions
and
51 deletions
pom.xml
| @@ -22,15 +22,15 @@ | @@ -22,15 +22,15 @@ | ||
| 22 | <maven.compiler.target>${java.version}</maven.compiler.target> | 22 | <maven.compiler.target>${java.version}</maven.compiler.target> |
| 23 | <maven-compiler-plugin.version>3.8.0</maven-compiler-plugin.version> | 23 | <maven-compiler-plugin.version>3.8.0</maven-compiler-plugin.version> |
| 24 | <!-- 统一依赖管理 --> | 24 | <!-- 统一依赖管理 --> |
| 25 | - <spring.boot.version>2.4.2</spring.boot.version> | 25 | + <spring.boot.version>2.4.4</spring.boot.version> |
| 26 | <!-- Web 相关 --> | 26 | <!-- Web 相关 --> |
| 27 | <knife4j.version>3.0.2</knife4j.version> | 27 | <knife4j.version>3.0.2</knife4j.version> |
| 28 | <swagger-annotations.version>1.5.22</swagger-annotations.version> | 28 | <swagger-annotations.version>1.5.22</swagger-annotations.version> |
| 29 | <!-- DB 相关 --> | 29 | <!-- DB 相关 --> |
| 30 | <mysql-connector-java.version>5.1.46</mysql-connector-java.version> | 30 | <mysql-connector-java.version>5.1.46</mysql-connector-java.version> |
| 31 | <druid.version>1.2.4</druid.version> | 31 | <druid.version>1.2.4</druid.version> |
| 32 | - <mybatis-plus.version>3.4.1</mybatis-plus.version> | ||
| 33 | - <redisson.version>3.14.1</redisson.version> | 32 | + <mybatis-plus.version>3.4.2</mybatis-plus.version> |
| 33 | + <redisson.version>3.15.1</redisson.version> | ||
| 34 | <!-- Config 配置中心相关 --> | 34 | <!-- Config 配置中心相关 --> |
| 35 | <apollo.version>1.7.0</apollo.version> | 35 | <apollo.version>1.7.0</apollo.version> |
| 36 | <!-- 服务保障相关 --> | 36 | <!-- 服务保障相关 --> |
| @@ -42,7 +42,7 @@ | @@ -42,7 +42,7 @@ | ||
| 42 | <!-- 工具类相关 --> | 42 | <!-- 工具类相关 --> |
| 43 | <lombok.version>1.16.14</lombok.version> | 43 | <lombok.version>1.16.14</lombok.version> |
| 44 | <mapstruct.version>1.4.1.Final</mapstruct.version> | 44 | <mapstruct.version>1.4.1.Final</mapstruct.version> |
| 45 | - <hutool.version>5.5.6</hutool.version> | 45 | + <hutool.version>5.6.1</hutool.version> |
| 46 | <easyexcel.verion>2.2.7</easyexcel.verion> | 46 | <easyexcel.verion>2.2.7</easyexcel.verion> |
| 47 | <velocity.version>2.2</velocity.version> | 47 | <velocity.version>2.2</velocity.version> |
| 48 | <screw.version>1.0.5</screw.version> | 48 | <screw.version>1.0.5</screw.version> |
| @@ -249,27 +249,7 @@ | @@ -249,27 +249,7 @@ | ||
| 249 | 249 | ||
| 250 | <dependency> | 250 | <dependency> |
| 251 | <groupId>cn.hutool</groupId> | 251 | <groupId>cn.hutool</groupId> |
| 252 | - <artifactId>hutool-core</artifactId> | ||
| 253 | - <version>${hutool.version}</version> | ||
| 254 | - </dependency> | ||
| 255 | - <dependency> | ||
| 256 | - <groupId>cn.hutool</groupId> | ||
| 257 | - <artifactId>hutool-extra</artifactId> | ||
| 258 | - <version>${hutool.version}</version> | ||
| 259 | - </dependency> | ||
| 260 | - <dependency> | ||
| 261 | - <groupId>cn.hutool</groupId> | ||
| 262 | - <artifactId>hutool-captcha</artifactId> | ||
| 263 | - <version>${hutool.version}</version> | ||
| 264 | - </dependency> | ||
| 265 | - <dependency> | ||
| 266 | - <groupId>cn.hutool</groupId> | ||
| 267 | - <artifactId>hutool-http</artifactId> | ||
| 268 | - <version>${hutool.version}</version> | ||
| 269 | - </dependency> | ||
| 270 | - <dependency> | ||
| 271 | - <groupId>cn.hutool</groupId> | ||
| 272 | - <artifactId>hutool-crypto</artifactId> | 252 | + <artifactId>hutool-all</artifactId> |
| 273 | <version>${hutool.version}</version> | 253 | <version>${hutool.version}</version> |
| 274 | </dependency> | 254 | </dependency> |
| 275 | 255 |
src/main/java/cn/iocoder/dashboard/framework/redis/config/RedisConfig.java
| 1 | package cn.iocoder.dashboard.framework.redis.config; | 1 | package cn.iocoder.dashboard.framework.redis.config; |
| 2 | 2 | ||
| 3 | +import cn.hutool.system.SystemUtil; | ||
| 3 | import cn.iocoder.dashboard.framework.redis.core.pubsub.AbstractChannelMessageListener; | 4 | import cn.iocoder.dashboard.framework.redis.core.pubsub.AbstractChannelMessageListener; |
| 5 | +import cn.iocoder.dashboard.framework.redis.core.stream.AbstractStreamMessageListener; | ||
| 4 | import lombok.extern.slf4j.Slf4j; | 6 | import lombok.extern.slf4j.Slf4j; |
| 5 | import org.springframework.context.annotation.Bean; | 7 | import org.springframework.context.annotation.Bean; |
| 6 | import org.springframework.context.annotation.Configuration; | 8 | import org.springframework.context.annotation.Configuration; |
| 7 | import org.springframework.data.redis.connection.RedisConnectionFactory; | 9 | import org.springframework.data.redis.connection.RedisConnectionFactory; |
| 10 | +import org.springframework.data.redis.connection.stream.Consumer; | ||
| 11 | +import org.springframework.data.redis.connection.stream.ObjectRecord; | ||
| 12 | +import org.springframework.data.redis.connection.stream.ReadOffset; | ||
| 13 | +import org.springframework.data.redis.connection.stream.StreamOffset; | ||
| 8 | import org.springframework.data.redis.core.RedisTemplate; | 14 | import org.springframework.data.redis.core.RedisTemplate; |
| 9 | import org.springframework.data.redis.listener.ChannelTopic; | 15 | import org.springframework.data.redis.listener.ChannelTopic; |
| 10 | import org.springframework.data.redis.listener.RedisMessageListenerContainer; | 16 | import org.springframework.data.redis.listener.RedisMessageListenerContainer; |
| 11 | import org.springframework.data.redis.serializer.RedisSerializer; | 17 | import org.springframework.data.redis.serializer.RedisSerializer; |
| 18 | +import org.springframework.data.redis.stream.StreamMessageListenerContainer; | ||
| 12 | 19 | ||
| 13 | import java.util.List; | 20 | import java.util.List; |
| 14 | 21 | ||
| @@ -19,6 +26,9 @@ import java.util.List; | @@ -19,6 +26,9 @@ import java.util.List; | ||
| 19 | @Slf4j | 26 | @Slf4j |
| 20 | public class RedisConfig { | 27 | public class RedisConfig { |
| 21 | 28 | ||
| 29 | + /** | ||
| 30 | + * 创建 RedisTemplate Bean,使用 JSON 序列化方式 | ||
| 31 | + */ | ||
| 22 | @Bean | 32 | @Bean |
| 23 | public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { | 33 | public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { |
| 24 | // 创建 RedisTemplate 对象 | 34 | // 创建 RedisTemplate 对象 |
| @@ -27,11 +37,16 @@ public class RedisConfig { | @@ -27,11 +37,16 @@ public class RedisConfig { | ||
| 27 | template.setConnectionFactory(factory); | 37 | template.setConnectionFactory(factory); |
| 28 | // 使用 String 序列化方式,序列化 KEY 。 | 38 | // 使用 String 序列化方式,序列化 KEY 。 |
| 29 | template.setKeySerializer(RedisSerializer.string()); | 39 | template.setKeySerializer(RedisSerializer.string()); |
| 40 | + template.setHashKeySerializer(RedisSerializer.string()); | ||
| 30 | // 使用 JSON 序列化方式(库是 Jackson ),序列化 VALUE 。 | 41 | // 使用 JSON 序列化方式(库是 Jackson ),序列化 VALUE 。 |
| 31 | template.setValueSerializer(RedisSerializer.json()); | 42 | template.setValueSerializer(RedisSerializer.json()); |
| 43 | + template.setHashValueSerializer(RedisSerializer.json()); | ||
| 32 | return template; | 44 | return template; |
| 33 | } | 45 | } |
| 34 | 46 | ||
| 47 | + /** | ||
| 48 | + * 创建 Redis Pub/Sub 广播消费的容器 | ||
| 49 | + */ | ||
| 35 | @Bean | 50 | @Bean |
| 36 | public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory factory, | 51 | public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory factory, |
| 37 | List<AbstractChannelMessageListener<?>> listeners) { | 52 | List<AbstractChannelMessageListener<?>> listeners) { |
| @@ -48,4 +63,51 @@ public class RedisConfig { | @@ -48,4 +63,51 @@ public class RedisConfig { | ||
| 48 | return container; | 63 | return container; |
| 49 | } | 64 | } |
| 50 | 65 | ||
| 66 | + /** | ||
| 67 | + * 创建 Redis Stream 集群消费的容器 | ||
| 68 | + * | ||
| 69 | + * Redis Stream 的 xreadgroup 命令:https://www.geek-book.com/src/docs/redis/redis/redis.io/commands/xreadgroup.html | ||
| 70 | + */ | ||
| 71 | + @Bean(initMethod = "start", destroyMethod = "stop") | ||
| 72 | + public StreamMessageListenerContainer<String, ObjectRecord<String, String>> redisStreamMessageListenerContainer(RedisTemplate<String, Object> redisTemplate, | ||
| 73 | + List<AbstractStreamMessageListener<?>> listeners) { | ||
| 74 | + // 第一步,创建 StreamMessageListenerContainer 容器 | ||
| 75 | + // 创建 options 配置 | ||
| 76 | + StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, ObjectRecord<String, String>> containerOptions = | ||
| 77 | + StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder() | ||
| 78 | + .batchSize(10) // 一次性最多拉取多少条消息 | ||
| 79 | + .targetType(String.class) // 目标类型。统一使用 String,通过自己封装的 AbstractStreamMessageListener 去反序列化 | ||
| 80 | + .build(); | ||
| 81 | + // 创建 container 对象 | ||
| 82 | + StreamMessageListenerContainer<String, ObjectRecord<String, String>> container = StreamMessageListenerContainer.create( | ||
| 83 | + redisTemplate.getRequiredConnectionFactory(), containerOptions); | ||
| 84 | + | ||
| 85 | + // 第二步,注册监听器,消费对应的 Stream 主题 | ||
| 86 | +// String consumerName = buildConsumerName(); | ||
| 87 | + String consumerName = "110"; | ||
| 88 | + listeners.forEach(listener -> { | ||
| 89 | + // 创建 listener 对应的消费者分组 | ||
| 90 | + try { | ||
| 91 | + redisTemplate.opsForStream().createGroup(listener.getStreamKey(), listener.getGroup()); | ||
| 92 | + } catch (Exception ignore) {} | ||
| 93 | + // 设置 listener 对应的 redisTemplate | ||
| 94 | + listener.setRedisTemplate(redisTemplate); | ||
| 95 | + // 创建 Consumer 对象 | ||
| 96 | + Consumer consumer = Consumer.from(listener.getGroup(), consumerName); | ||
| 97 | + // 设置 Consumer 消费进度,以最小消费进度为准 | ||
| 98 | + StreamOffset<String> streamOffset = StreamOffset.create(listener.getStreamKey(), ReadOffset.lastConsumed()); | ||
| 99 | + // 设置 Consumer 监听 | ||
| 100 | + StreamMessageListenerContainer.StreamReadRequestBuilder<String> builder = StreamMessageListenerContainer.StreamReadRequest | ||
| 101 | + .builder(streamOffset).consumer(consumer) | ||
| 102 | + .autoAcknowledge(false) // 不自动 ack | ||
| 103 | + .cancelOnError(throwable -> false); // 默认配置,发生异常就取消消费,显然不符合预期;因此,我们设置为 false | ||
| 104 | + container.register(builder.build(), listener); | ||
| 105 | + }); | ||
| 106 | + return container; | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + private static String buildConsumerName() { | ||
| 110 | + return String.format("%s@%d", SystemUtil.getHostInfo().getAddress(), SystemUtil.getCurrentPID()); | ||
| 111 | + } | ||
| 112 | + | ||
| 51 | } | 113 | } |
src/main/java/cn/iocoder/dashboard/framework/redis/core/pubsub/AbstractChannelMessageListener.java
| 1 | package cn.iocoder.dashboard.framework.redis.core.pubsub; | 1 | package cn.iocoder.dashboard.framework.redis.core.pubsub; |
| 2 | 2 | ||
| 3 | -import cn.hutool.core.util.ArrayUtil; | 3 | +import cn.hutool.core.util.TypeUtil; |
| 4 | import cn.iocoder.dashboard.util.json.JsonUtils; | 4 | import cn.iocoder.dashboard.util.json.JsonUtils; |
| 5 | import lombok.SneakyThrows; | 5 | import lombok.SneakyThrows; |
| 6 | import org.springframework.data.redis.connection.Message; | 6 | import org.springframework.data.redis.connection.Message; |
| 7 | import org.springframework.data.redis.connection.MessageListener; | 7 | import org.springframework.data.redis.connection.MessageListener; |
| 8 | -import sun.reflect.generics.reflectiveObjects.ParameterizedTypeImpl; | ||
| 9 | 8 | ||
| 10 | import java.lang.reflect.Type; | 9 | import java.lang.reflect.Type; |
| 11 | 10 | ||
| @@ -62,21 +61,11 @@ public abstract class AbstractChannelMessageListener<T extends ChannelMessage> i | @@ -62,21 +61,11 @@ public abstract class AbstractChannelMessageListener<T extends ChannelMessage> i | ||
| 62 | */ | 61 | */ |
| 63 | @SuppressWarnings("unchecked") | 62 | @SuppressWarnings("unchecked") |
| 64 | private Class<T> getMessageClass() { | 63 | private Class<T> getMessageClass() { |
| 65 | - Class<?> targetClass = getClass(); | ||
| 66 | - while (targetClass.getSuperclass() != null) { | ||
| 67 | - // 如果不是 AbstractMessageListener 父类,继续向上查找 | ||
| 68 | - if (targetClass.getSuperclass() != AbstractChannelMessageListener.class) { | ||
| 69 | - targetClass = targetClass.getSuperclass(); | ||
| 70 | - continue; | ||
| 71 | - } | ||
| 72 | - // 如果是 AbstractMessageListener 父类,则解析泛型 | ||
| 73 | - Type[] types = ((ParameterizedTypeImpl) targetClass.getGenericSuperclass()).getActualTypeArguments(); | ||
| 74 | - if (ArrayUtil.isEmpty(types)) { | ||
| 75 | - throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName())); | ||
| 76 | - } | ||
| 77 | - return (Class<T>) types[0]; | 64 | + Type type = TypeUtil.getTypeArgument(getClass(), 0); |
| 65 | + if (type == null) { | ||
| 66 | + throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName())); | ||
| 78 | } | 67 | } |
| 79 | - throw new IllegalStateException(String.format("类型(%s) 找不到 AbstractMessageListener 父类", getClass().getName())); | 68 | + return (Class<T>) type; |
| 80 | } | 69 | } |
| 81 | 70 | ||
| 82 | } | 71 | } |
src/main/java/cn/iocoder/dashboard/framework/redis/core/pubsub/ChannelMessage.java
| @@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; | @@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; | ||
| 4 | 4 | ||
| 5 | /** | 5 | /** |
| 6 | * Redis Channel Message 接口 | 6 | * Redis Channel Message 接口 |
| 7 | + * | ||
| 8 | + * @author 芋道源码 | ||
| 7 | */ | 9 | */ |
| 8 | public interface ChannelMessage { | 10 | public interface ChannelMessage { |
| 9 | 11 | ||
| @@ -12,7 +14,7 @@ public interface ChannelMessage { | @@ -12,7 +14,7 @@ public interface ChannelMessage { | ||
| 12 | * | 14 | * |
| 13 | * @return Channel | 15 | * @return Channel |
| 14 | */ | 16 | */ |
| 15 | - @JsonIgnore // 必须序列化 | 17 | + @JsonIgnore // 避免序列化 |
| 16 | String getChannel(); | 18 | String getChannel(); |
| 17 | 19 | ||
| 18 | } | 20 | } |
src/main/java/cn/iocoder/dashboard/framework/redis/core/stream/AbstractStreamMessageListener.java
0 → 100644
| 1 | +package cn.iocoder.dashboard.framework.redis.core.stream; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.util.TypeUtil; | ||
| 4 | +import cn.iocoder.dashboard.util.json.JsonUtils; | ||
| 5 | +import lombok.Getter; | ||
| 6 | +import lombok.Setter; | ||
| 7 | +import lombok.SneakyThrows; | ||
| 8 | +import org.springframework.beans.factory.annotation.Value; | ||
| 9 | +import org.springframework.data.redis.connection.stream.ObjectRecord; | ||
| 10 | +import org.springframework.data.redis.core.RedisTemplate; | ||
| 11 | +import org.springframework.data.redis.stream.StreamListener; | ||
| 12 | + | ||
| 13 | +import java.lang.reflect.Type; | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * Redis Stream 监听器抽象类,用于实现集群消费 | ||
| 17 | + * | ||
| 18 | + * @param <T> 消息类型。一定要填写噢,不然会报错 | ||
| 19 | + * | ||
| 20 | + * @author 芋道源码 | ||
| 21 | + */ | ||
| 22 | +public abstract class AbstractStreamMessageListener<T extends StreamMessage> | ||
| 23 | + implements StreamListener<String, ObjectRecord<String, String>> { | ||
| 24 | + | ||
| 25 | + /** | ||
| 26 | + * 消息类型 | ||
| 27 | + */ | ||
| 28 | + private final Class<T> messageType; | ||
| 29 | + /** | ||
| 30 | + * Redis Channel | ||
| 31 | + */ | ||
| 32 | + @Getter | ||
| 33 | + private final String streamKey; | ||
| 34 | + | ||
| 35 | + /** | ||
| 36 | + * Redis 消费者分组,默认使用 spring.application.name 名字 | ||
| 37 | + */ | ||
| 38 | + @Value("${spring.application.name}") | ||
| 39 | + @Getter | ||
| 40 | + private String group; | ||
| 41 | + /** | ||
| 42 | + * | ||
| 43 | + */ | ||
| 44 | + @Setter | ||
| 45 | + private RedisTemplate<String, ?> redisTemplate; | ||
| 46 | + | ||
| 47 | + @SneakyThrows | ||
| 48 | + protected AbstractStreamMessageListener() { | ||
| 49 | + this.messageType = getMessageClass(); | ||
| 50 | + this.streamKey = messageType.newInstance().getStreamKey(); | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + @Override | ||
| 54 | + public void onMessage(ObjectRecord<String, String> message) { | ||
| 55 | + // 消费消息 | ||
| 56 | + T messageObj = JsonUtils.parseObject(message.getValue(), messageType); | ||
| 57 | + this.onMessage(messageObj); | ||
| 58 | + // ack 消息消费完成 | ||
| 59 | + redisTemplate.opsForStream().acknowledge(group, message); | ||
| 60 | + // TODO 芋艿:需要额外考虑以下几个点: | ||
| 61 | + // 1. 处理异常的情况 | ||
| 62 | + // 2. 发送日志;以及事务的结合 | ||
| 63 | + // 3. 消费日志;以及通用的幂等性 | ||
| 64 | + // 4. 消费失败的重试,https://zhuanlan.zhihu.com/p/60501638 | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + /** | ||
| 68 | + * 处理消息 | ||
| 69 | + * | ||
| 70 | + * @param message 消息 | ||
| 71 | + */ | ||
| 72 | + public abstract void onMessage(T message); | ||
| 73 | + | ||
| 74 | + /** | ||
| 75 | + * 通过解析类上的泛型,获得消息类型 | ||
| 76 | + * | ||
| 77 | + * @return 消息类型 | ||
| 78 | + */ | ||
| 79 | + @SuppressWarnings("unchecked") | ||
| 80 | + private Class<T> getMessageClass() { | ||
| 81 | + Type type = TypeUtil.getTypeArgument(getClass(), 0); | ||
| 82 | + if (type == null) { | ||
| 83 | + throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName())); | ||
| 84 | + } | ||
| 85 | + return (Class<T>) type; | ||
| 86 | + } | ||
| 87 | + | ||
| 88 | +} |
src/main/java/cn/iocoder/dashboard/framework/redis/core/stream/StreamMessage.java
0 → 100644
| 1 | +package cn.iocoder.dashboard.framework.redis.core.stream; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonIgnore; | ||
| 4 | + | ||
| 5 | +/** | ||
| 6 | + * Redis Stream Message 接口 | ||
| 7 | + * | ||
| 8 | + * @author 芋道源码 | ||
| 9 | + */ | ||
| 10 | +public interface StreamMessage { | ||
| 11 | + | ||
| 12 | + /** | ||
| 13 | + * 获得 Redis Stream Key | ||
| 14 | + * | ||
| 15 | + * @return Channel | ||
| 16 | + */ | ||
| 17 | + @JsonIgnore // 避免序列化 | ||
| 18 | + String getStreamKey(); | ||
| 19 | + | ||
| 20 | +} |
src/main/java/cn/iocoder/dashboard/framework/redis/core/util/RedisMessageUtils.java
| 1 | package cn.iocoder.dashboard.framework.redis.core.util; | 1 | package cn.iocoder.dashboard.framework.redis.core.util; |
| 2 | 2 | ||
| 3 | import cn.iocoder.dashboard.framework.redis.core.pubsub.ChannelMessage; | 3 | import cn.iocoder.dashboard.framework.redis.core.pubsub.ChannelMessage; |
| 4 | +import cn.iocoder.dashboard.framework.redis.core.stream.StreamMessage; | ||
| 4 | import cn.iocoder.dashboard.util.json.JsonUtils; | 5 | import cn.iocoder.dashboard.util.json.JsonUtils; |
| 6 | +import org.springframework.data.redis.connection.stream.RecordId; | ||
| 7 | +import org.springframework.data.redis.connection.stream.StreamRecords; | ||
| 5 | import org.springframework.data.redis.core.RedisTemplate; | 8 | import org.springframework.data.redis.core.RedisTemplate; |
| 6 | 9 | ||
| 7 | /** | 10 | /** |
| @@ -17,8 +20,21 @@ public class RedisMessageUtils { | @@ -17,8 +20,21 @@ public class RedisMessageUtils { | ||
| 17 | * @param redisTemplate Redis 操作模板 | 20 | * @param redisTemplate Redis 操作模板 |
| 18 | * @param message 消息 | 21 | * @param message 消息 |
| 19 | */ | 22 | */ |
| 20 | - public static <T extends ChannelMessage> void sendChannelMessage(RedisTemplate<?, ?> redisTemplate, T message) { | 23 | + public static <T extends ChannelMessage> void sendChannelMessage(RedisTemplate<?, ?> redisTemplate, T message) { |
| 21 | redisTemplate.convertAndSend(message.getChannel(), JsonUtils.toJsonString(message)); | 24 | redisTemplate.convertAndSend(message.getChannel(), JsonUtils.toJsonString(message)); |
| 22 | } | 25 | } |
| 23 | 26 | ||
| 27 | + /** | ||
| 28 | + * 发送 Redis 消息,基于 Redis Stream 实现 | ||
| 29 | + * | ||
| 30 | + * @param redisTemplate Redis 操作模板 | ||
| 31 | + * @param message 消息 | ||
| 32 | + * @return 消息记录的编号对象 | ||
| 33 | + */ | ||
| 34 | + public static <T extends StreamMessage> RecordId sendStreamMessage(RedisTemplate<String, ?> redisTemplate, T message) { | ||
| 35 | + return redisTemplate.opsForStream().add(StreamRecords.newRecord() | ||
| 36 | + .ofObject(JsonUtils.toJsonString(message)) // 设置内容 | ||
| 37 | + .withStreamKey(message.getStreamKey())); // 设置 stream key | ||
| 38 | + } | ||
| 39 | + | ||
| 24 | } | 40 | } |
src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/permission/SysRoleMenuMapper.java
| @@ -4,6 +4,7 @@ import cn.iocoder.dashboard.framework.mybatis.core.mapper.BaseMapperX; | @@ -4,6 +4,7 @@ import cn.iocoder.dashboard.framework.mybatis.core.mapper.BaseMapperX; | ||
| 4 | import cn.iocoder.dashboard.modules.system.dal.dataobject.permission.SysRoleMenuDO; | 4 | import cn.iocoder.dashboard.modules.system.dal.dataobject.permission.SysRoleMenuDO; |
| 5 | import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; | 5 | import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
| 6 | import org.apache.ibatis.annotations.Mapper; | 6 | import org.apache.ibatis.annotations.Mapper; |
| 7 | +import org.apache.ibatis.annotations.Select; | ||
| 7 | 8 | ||
| 8 | import java.util.Collection; | 9 | import java.util.Collection; |
| 9 | import java.util.Date; | 10 | import java.util.Date; |
| @@ -32,7 +33,7 @@ public interface SysRoleMenuMapper extends BaseMapperX<SysRoleMenuDO> { | @@ -32,7 +33,7 @@ public interface SysRoleMenuMapper extends BaseMapperX<SysRoleMenuDO> { | ||
| 32 | delete(new QueryWrapper<SysRoleMenuDO>().eq("role_id", roleId) | 33 | delete(new QueryWrapper<SysRoleMenuDO>().eq("role_id", roleId) |
| 33 | .in("menu_id", menuIds)); | 34 | .in("menu_id", menuIds)); |
| 34 | } | 35 | } |
| 35 | - | 36 | + |
| 36 | default void deleteListByMenuId(Long menuId) { | 37 | default void deleteListByMenuId(Long menuId) { |
| 37 | delete(new QueryWrapper<SysRoleMenuDO>().eq("menu_id", menuId)); | 38 | delete(new QueryWrapper<SysRoleMenuDO>().eq("menu_id", menuId)); |
| 38 | } | 39 | } |
| @@ -41,9 +42,7 @@ public interface SysRoleMenuMapper extends BaseMapperX<SysRoleMenuDO> { | @@ -41,9 +42,7 @@ public interface SysRoleMenuMapper extends BaseMapperX<SysRoleMenuDO> { | ||
| 41 | delete(new QueryWrapper<SysRoleMenuDO>().eq("role_id", roleId)); | 42 | delete(new QueryWrapper<SysRoleMenuDO>().eq("role_id", roleId)); |
| 42 | } | 43 | } |
| 43 | 44 | ||
| 44 | - default boolean selectExistsByUpdateTimeAfter(Date maxUpdateTime) { | ||
| 45 | - return selectOne(new QueryWrapper<SysRoleMenuDO>().select("id") | ||
| 46 | - .gt("update_time", maxUpdateTime).last("LIMIT 1")) != null; | ||
| 47 | - } | 45 | + @Select("SELECT id FROM sys_role_menu WHERE update_time > #{maxUpdateTime} LIMIT 1") |
| 46 | + Long selectExistsByUpdateTimeAfter(Date maxUpdateTime); | ||
| 48 | 47 | ||
| 49 | } | 48 | } |
src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/mail/SysMailSendConsumer.java
0 → 100644
| 1 | +package cn.iocoder.dashboard.modules.system.mq.consumer.mail; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.dashboard.framework.redis.core.stream.AbstractStreamMessageListener; | ||
| 4 | +import cn.iocoder.dashboard.modules.system.mq.message.mail.SysMailSendMessage; | ||
| 5 | +import lombok.extern.slf4j.Slf4j; | ||
| 6 | +import org.springframework.stereotype.Component; | ||
| 7 | + | ||
| 8 | +@Component | ||
| 9 | +@Slf4j | ||
| 10 | +public class SysMailSendConsumer extends AbstractStreamMessageListener<SysMailSendMessage> { | ||
| 11 | + | ||
| 12 | + @Override | ||
| 13 | + public void onMessage(SysMailSendMessage message) { | ||
| 14 | + log.info("[onMessage][消息内容({})]", message); | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | +} |
src/main/java/cn/iocoder/dashboard/modules/system/mq/consumer/sms/SysSmsSendConsumer.java
0 → 100644
| 1 | +package cn.iocoder.dashboard.modules.system.mq.consumer.sms; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.dashboard.framework.redis.core.stream.AbstractStreamMessageListener; | ||
| 4 | +import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsSendMessage; | ||
| 5 | +import lombok.extern.slf4j.Slf4j; | ||
| 6 | +import org.springframework.stereotype.Component; | ||
| 7 | + | ||
| 8 | +@Component | ||
| 9 | +@Slf4j | ||
| 10 | +public class SysSmsSendConsumer extends AbstractStreamMessageListener<SysSmsSendMessage> { | ||
| 11 | + | ||
| 12 | + @Override | ||
| 13 | + public void onMessage(SysSmsSendMessage message) { | ||
| 14 | + log.info("[onMessage][消息内容({})]", message); | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | +} |
src/main/java/cn/iocoder/dashboard/modules/system/mq/message/mail/SysMailSendMessage.java
0 → 100644
| 1 | +package cn.iocoder.dashboard.modules.system.mq.message.mail; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.dashboard.framework.redis.core.stream.StreamMessage; | ||
| 4 | +import lombok.Data; | ||
| 5 | + | ||
| 6 | +import javax.validation.constraints.NotNull; | ||
| 7 | +import java.util.Map; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * 邮箱发送消息 | ||
| 11 | + * | ||
| 12 | + * @author 芋道源码 | ||
| 13 | + */ | ||
| 14 | +@Data | ||
| 15 | +public class SysMailSendMessage implements StreamMessage { | ||
| 16 | + | ||
| 17 | + /** | ||
| 18 | + * 邮箱地址 | ||
| 19 | + */ | ||
| 20 | + @NotNull(message = "邮箱地址不能为空") | ||
| 21 | + private String address; | ||
| 22 | + /** | ||
| 23 | + * 短信模板编号 | ||
| 24 | + */ | ||
| 25 | + @NotNull(message = "短信模板编号不能为空") | ||
| 26 | + private String templateCode; | ||
| 27 | + /** | ||
| 28 | + * 短信模板参数 | ||
| 29 | + */ | ||
| 30 | + private Map<String, Object> templateParams; | ||
| 31 | + | ||
| 32 | + /** | ||
| 33 | + * 用户编号,允许空 | ||
| 34 | + */ | ||
| 35 | + private Integer userId; | ||
| 36 | + /** | ||
| 37 | + * 用户类型,允许空 | ||
| 38 | + */ | ||
| 39 | + private Integer userType; | ||
| 40 | + | ||
| 41 | + @Override | ||
| 42 | + public String getStreamKey() { | ||
| 43 | + return "system.mail.send"; | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | +} |
src/main/java/cn/iocoder/dashboard/modules/system/mq/message/sms/SysSmsSendMessage.java
0 → 100644
| 1 | +package cn.iocoder.dashboard.modules.system.mq.message.sms; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.dashboard.framework.redis.core.stream.StreamMessage; | ||
| 4 | +import lombok.Data; | ||
| 5 | + | ||
| 6 | +import javax.validation.constraints.NotNull; | ||
| 7 | +import java.util.Map; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * 短信发送消息 | ||
| 11 | + * | ||
| 12 | + * @author 芋道源码 | ||
| 13 | + */ | ||
| 14 | +@Data | ||
| 15 | +public class SysSmsSendMessage implements StreamMessage { | ||
| 16 | + | ||
| 17 | + /** | ||
| 18 | + * 手机号 | ||
| 19 | + */ | ||
| 20 | + @NotNull(message = "手机号不能为空") | ||
| 21 | + private String mobile; | ||
| 22 | + /** | ||
| 23 | + * 短信模板编号 | ||
| 24 | + */ | ||
| 25 | + @NotNull(message = "短信模板编号不能为空") | ||
| 26 | + private String templateCode; | ||
| 27 | + /** | ||
| 28 | + * 短信模板参数 | ||
| 29 | + */ | ||
| 30 | + private Map<String, Object> templateParams; | ||
| 31 | + | ||
| 32 | + /** | ||
| 33 | + * 用户编号,允许空 | ||
| 34 | + */ | ||
| 35 | + private Integer userId; | ||
| 36 | + /** | ||
| 37 | + * 用户类型,允许空 | ||
| 38 | + */ | ||
| 39 | + private Integer userType; | ||
| 40 | + | ||
| 41 | + @Override | ||
| 42 | + public String getStreamKey() { | ||
| 43 | + return "system.sms.send"; | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | +} |
src/main/java/cn/iocoder/dashboard/modules/system/service/permission/impl/SysPermissionServiceImpl.java
| @@ -86,6 +86,7 @@ public class SysPermissionServiceImpl implements SysPermissionService { | @@ -86,6 +86,7 @@ public class SysPermissionServiceImpl implements SysPermissionService { | ||
| 86 | @Override | 86 | @Override |
| 87 | @PostConstruct | 87 | @PostConstruct |
| 88 | public void initLocalCache() { | 88 | public void initLocalCache() { |
| 89 | + Date now = new Date(); | ||
| 89 | // 获取角色与菜单的关联列表,如果有更新 | 90 | // 获取角色与菜单的关联列表,如果有更新 |
| 90 | List<SysRoleMenuDO> roleMenuList = this.loadRoleMenuIfUpdate(maxUpdateTime); | 91 | List<SysRoleMenuDO> roleMenuList = this.loadRoleMenuIfUpdate(maxUpdateTime); |
| 91 | if (CollUtil.isEmpty(roleMenuList)) { | 92 | if (CollUtil.isEmpty(roleMenuList)) { |
| @@ -102,7 +103,7 @@ public class SysPermissionServiceImpl implements SysPermissionService { | @@ -102,7 +103,7 @@ public class SysPermissionServiceImpl implements SysPermissionService { | ||
| 102 | roleMenuCache = roleMenuCacheBuilder.build(); | 103 | roleMenuCache = roleMenuCacheBuilder.build(); |
| 103 | menuRoleCache = menuRoleCacheBuilder.build(); | 104 | menuRoleCache = menuRoleCacheBuilder.build(); |
| 104 | assert roleMenuList.size() > 0; // 断言,避免告警 | 105 | assert roleMenuList.size() > 0; // 断言,避免告警 |
| 105 | - maxUpdateTime = roleMenuList.stream().max(Comparator.comparing(BaseDO::getUpdateTime)).get().getUpdateTime(); | 106 | + maxUpdateTime = now; |
| 106 | log.info("[initLocalCache][初始化角色与菜单的关联数量为 {}]", roleMenuList.size()); | 107 | log.info("[initLocalCache][初始化角色与菜单的关联数量为 {}]", roleMenuList.size()); |
| 107 | } | 108 | } |
| 108 | 109 | ||
| @@ -123,7 +124,7 @@ public class SysPermissionServiceImpl implements SysPermissionService { | @@ -123,7 +124,7 @@ public class SysPermissionServiceImpl implements SysPermissionService { | ||
| 123 | if (maxUpdateTime == null) { // 如果更新时间为空,说明 DB 一定有新数据 | 124 | if (maxUpdateTime == null) { // 如果更新时间为空,说明 DB 一定有新数据 |
| 124 | log.info("[loadRoleMenuIfUpdate][首次加载全量角色与菜单的关联]"); | 125 | log.info("[loadRoleMenuIfUpdate][首次加载全量角色与菜单的关联]"); |
| 125 | } else { // 判断数据库中是否有更新的角色与菜单的关联 | 126 | } else { // 判断数据库中是否有更新的角色与菜单的关联 |
| 126 | - if (!roleMenuMapper.selectExistsByUpdateTimeAfter(maxUpdateTime)) { | 127 | + if (Objects.isNull(roleMenuMapper.selectExistsByUpdateTimeAfter(maxUpdateTime))) { |
| 127 | return null; | 128 | return null; |
| 128 | } | 129 | } |
| 129 | log.info("[loadRoleMenuIfUpdate][增量加载全量角色与菜单的关联]"); | 130 | log.info("[loadRoleMenuIfUpdate][增量加载全量角色与菜单的关联]"); |
src/test-integration/java/cn/iocoder/dashboard/BaseRedisIntegrationTest.java
0 → 100644
| 1 | +package cn.iocoder.dashboard; | ||
| 2 | + | ||
| 3 | +import cn.iocoder.dashboard.framework.redis.config.RedisConfig; | ||
| 4 | +import org.redisson.spring.starter.RedissonAutoConfiguration; | ||
| 5 | +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; | ||
| 6 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 7 | +import org.springframework.context.annotation.Import; | ||
| 8 | +import org.springframework.test.context.ActiveProfiles; | ||
| 9 | + | ||
| 10 | +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseRedisIntegrationTest.Application.class) | ||
| 11 | +@ActiveProfiles("integration-test") // 设置使用 application-integration-test 配置文件 | ||
| 12 | +public class BaseRedisIntegrationTest { | ||
| 13 | + | ||
| 14 | + @Import({ | ||
| 15 | + // Redis 配置类 | ||
| 16 | + RedisAutoConfiguration.class, // Spring Redis 自动配置类 | ||
| 17 | + RedisConfig.class, // 自己的 Redis 配置类 | ||
| 18 | + RedissonAutoConfiguration.class, // Redisson 自动高配置类 | ||
| 19 | + }) | ||
| 20 | + public static class Application { | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | +} |
src/test-integration/java/cn/iocoder/dashboard/framework/redis/core/stream/RedisStreamTest.java
0 → 100644
| 1 | +package cn.iocoder.dashboard.framework.redis.core.stream; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.thread.ThreadUtil; | ||
| 4 | +import cn.iocoder.dashboard.BaseRedisIntegrationTest; | ||
| 5 | +import cn.iocoder.dashboard.framework.redis.core.util.RedisMessageUtils; | ||
| 6 | +import cn.iocoder.dashboard.modules.system.mq.consumer.mail.SysMailSendConsumer; | ||
| 7 | +import cn.iocoder.dashboard.modules.system.mq.consumer.sms.SysSmsSendConsumer; | ||
| 8 | +import cn.iocoder.dashboard.modules.system.mq.message.mail.SysMailSendMessage; | ||
| 9 | +import cn.iocoder.dashboard.modules.system.mq.message.sms.SysSmsSendMessage; | ||
| 10 | +import org.junit.jupiter.api.Test; | ||
| 11 | +import org.springframework.context.annotation.Import; | ||
| 12 | +import org.springframework.data.redis.core.RedisTemplate; | ||
| 13 | +import org.springframework.data.redis.core.StringRedisTemplate; | ||
| 14 | + | ||
| 15 | +import javax.annotation.Resource; | ||
| 16 | +import java.util.concurrent.TimeUnit; | ||
| 17 | + | ||
| 18 | +public class RedisStreamTest { | ||
| 19 | + | ||
| 20 | + @Import({SysSmsSendConsumer.class, SysMailSendConsumer.class}) | ||
| 21 | + public static class ConsumerTest extends BaseRedisIntegrationTest { | ||
| 22 | + | ||
| 23 | + @Test | ||
| 24 | + public void testConsumer() { | ||
| 25 | + ThreadUtil.sleep(1, TimeUnit.DAYS); | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + public static class ProducerTest extends BaseRedisIntegrationTest { | ||
| 31 | + | ||
| 32 | + @Resource | ||
| 33 | + private StringRedisTemplate stringRedisTemplate; | ||
| 34 | + | ||
| 35 | + @Resource | ||
| 36 | + private RedisTemplate<String, Object> redisTemplate; | ||
| 37 | + | ||
| 38 | + @Test | ||
| 39 | + public void testProducer01() { | ||
| 40 | + for (int i = 0; i < 100; i++) { | ||
| 41 | + // 创建消息 | ||
| 42 | + SysSmsSendMessage message = new SysSmsSendMessage(); | ||
| 43 | + message.setMobile("15601691300").setTemplateCode("test:" + i); | ||
| 44 | + // 发送消息 | ||
| 45 | + RedisMessageUtils.sendStreamMessage(stringRedisTemplate, message); | ||
| 46 | + } | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + @Test | ||
| 50 | + public void testProducer02() { | ||
| 51 | + // 创建消息 | ||
| 52 | + SysMailSendMessage message = new SysMailSendMessage(); | ||
| 53 | + message.setAddress("fangfang@mihayou.com").setTemplateCode("test"); | ||
| 54 | + // 发送消息 | ||
| 55 | + RedisMessageUtils.sendStreamMessage(stringRedisTemplate, message); | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | +} |
src/test-integration/resources/application-integration-test.yaml
0 → 100644
| 1 | +spring: | ||
| 2 | + main: | ||
| 3 | + lazy-initialization: true # 开启懒加载,加快速度 | ||
| 4 | + banner-mode: off # 单元测试,禁用 Banner | ||
| 5 | + | ||
| 6 | +--- #################### 数据库相关配置 #################### | ||
| 7 | + | ||
| 8 | +spring: | ||
| 9 | + # 数据源配置项 | ||
| 10 | + datasource: | ||
| 11 | + name: ruoyi-vue-pro | ||
| 12 | + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 | ||
| 13 | + driver-class-name: org.h2.Driver | ||
| 14 | + username: sa | ||
| 15 | + password: | ||
| 16 | + schema: classpath:sql/create_tables.sql # MySQL 转 H2 的语句,使用 https://www.jooq.org/translate/ 工具 | ||
| 17 | + druid: | ||
| 18 | + async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度 | ||
| 19 | + initial-size: 1 # 单元测试,配置为 1,提升启动速度 | ||
| 20 | + | ||
| 21 | + # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 | ||
| 22 | + redis: | ||
| 23 | + host: 127.0.0.1 # 地址 | ||
| 24 | + port: 6379 # 端口(单元测试,使用 16379 端口) | ||
| 25 | + database: 0 # 数据库索引 | ||
| 26 | + | ||
| 27 | +mybatis: | ||
| 28 | + lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试 | ||
| 29 | + | ||
| 30 | +--- #################### 定时任务相关配置 #################### | ||
| 31 | + | ||
| 32 | +--- #################### 配置中心相关配置 #################### | ||
| 33 | + | ||
| 34 | +--- #################### 服务保障相关配置 #################### | ||
| 35 | + | ||
| 36 | +# Lock4j 配置项(单元测试,禁用 Lock4j) | ||
| 37 | + | ||
| 38 | +# Resilience4j 配置项 | ||
| 39 | +resilience4j: | ||
| 40 | + ratelimiter: | ||
| 41 | + instances: | ||
| 42 | + backendA: | ||
| 43 | + limit-for-period: 1 # 每个周期内,允许的请求数。默认为 50 | ||
| 44 | + limit-refresh-period: 60s # 每个周期的时长,单位:微秒。默认为 500 | ||
| 45 | + timeout-duration: 1s # 被限流时,阻塞等待的时长,单位:微秒。默认为 5s | ||
| 46 | + register-health-indicator: true # 是否注册到健康监测 | ||
| 47 | + | ||
| 48 | +--- #################### 监控相关配置 #################### | ||
| 49 | + | ||
| 50 | +--- #################### 芋道相关配置 #################### | ||
| 51 | + | ||
| 52 | +# 芋道配置项,设置当前项目所有自定义的配置 | ||
| 53 | +yudao: | ||
| 54 | + info: | ||
| 55 | + version: 1.0.0 | ||
| 56 | + base-package: cn.iocoder.dashboard | ||
| 57 | + web: | ||
| 58 | + api-prefix: /api | ||
| 59 | + controller-package: ${yudao.info.base-package} | ||
| 60 | + security: | ||
| 61 | + token-header: Authorization | ||
| 62 | + token-secret: abcdefghijklmnopqrstuvwxyz | ||
| 63 | + token-timeout: 1d | ||
| 64 | + session-timeout: 30m | ||
| 65 | + mock-enable: true | ||
| 66 | + mock-secret: test | ||
| 67 | + swagger: | ||
| 68 | + enable: false # 单元测试,禁用 Swagger | ||
| 69 | + captcha: | ||
| 70 | + timeout: 5m | ||
| 71 | + width: 160 | ||
| 72 | + height: 60 | ||
| 73 | + file: | ||
| 74 | + base-path: http://127.0.0.1:${server.port}/${yudao.web.api-prefix}/file/get/ | ||
| 75 | + codegen: | ||
| 76 | + base-package: ${yudao.info.base-package}.modules | ||
| 77 | + db-schemas: ${spring.datasource.name} | ||
| 78 | + xss: | ||
| 79 | + enable: false | ||
| 80 | + exclude-urls: # 如下两个 url,仅仅是为了演示,去掉配置也没关系 | ||
| 81 | + - ${spring.boot.admin.context-path}/** # 不处理 Spring Boot Admin 的请求 | ||
| 82 | + - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 |
src/test/java/cn/iocoder/dashboard/modules/system/service/dept/SysDeptServiceTest.java
| @@ -72,7 +72,6 @@ class SysDeptServiceTest extends BaseDbUnitTest { | @@ -72,7 +72,6 @@ class SysDeptServiceTest extends BaseDbUnitTest { | ||
| 72 | // 断言 maxUpdateTime 缓存 | 72 | // 断言 maxUpdateTime 缓存 |
| 73 | Date maxUpdateTime = (Date) getFieldValue(deptService, "maxUpdateTime"); | 73 | Date maxUpdateTime = (Date) getFieldValue(deptService, "maxUpdateTime"); |
| 74 | assertEquals(ObjectUtils.max(deptDO1.getUpdateTime(), deptDO2.getUpdateTime()), maxUpdateTime); | 74 | assertEquals(ObjectUtils.max(deptDO1.getUpdateTime(), deptDO2.getUpdateTime()), maxUpdateTime); |
| 75 | - | ||
| 76 | } | 75 | } |
| 77 | 76 | ||
| 78 | @Test | 77 | @Test |