diff --git a/backend/pom.xml b/backend/pom.xml index 218e73248..97d8c7f65 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -30,6 +30,19 @@ org.springframework.boot spring-boot-starter-amqp + + org.springframework.boot + spring-boot-starter-data-redis + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.fasterxml.jackson.datatype + jackson-datatype-hibernate6 + 2.20.0 + org.slf4j slf4j-api diff --git a/backend/src/main/java/com/openisle/config/CachingConfig.java b/backend/src/main/java/com/openisle/config/CachingConfig.java new file mode 100644 index 000000000..ded16138a --- /dev/null +++ b/backend/src/main/java/com/openisle/config/CachingConfig.java @@ -0,0 +1,109 @@ +package com.openisle.config; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +/** + * Redis 缓存配置类 + * @author smallclover + * @since 2025-09-04 + */ +@Configuration +@EnableCaching +public class CachingConfig { + + // 标签缓存名 + public static final String TAG_CACHE_NAME="openisle_tags"; + // 分类缓存名 + public static final String CATEGORY_CACHE_NAME="openisle_categories"; + + /** + * 自定义Redis的序列化器 + * @return + */ + @Bean() + @Primary + public RedisSerializer redisSerializer() { + // 注册 JavaTimeModule 來支持 Java 8 的日期和时间 API,否则回报一下错误,同时还要引入jsr310 + + // org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default: + // add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling + // (through reference chain: java.util.ArrayList[0]->com.openisle.dto.TagDto["createdAt"]) + // 设置可见性,允许序列化所有元素 + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + // Hibernate6Module 可以自动处理懒加载代理对象。 + // Tag对象的creator是FetchType.LAZY + objectMapper.registerModule(new Hibernate6Module() + .disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION)); + // service的时候带上类型信息 + // 启用类型信息,避免 LinkedHashMap 问题 + objectMapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + return new GenericJackson2JsonRedisSerializer(objectMapper); + } + + /** + * 配置 Spring Cache 使用 RedisCacheManager + */ + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory, RedisSerializer redisSerializer) { + + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ZERO) // 默认缓存不过期 + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)) + .disableCachingNullValues(); // 禁止缓存 null 值 +// 个别缓存单独设置TTL时间 +// Map cacheConfigs = new HashMap<>(); +// cacheConfigs.put("openisle_tags", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ZERO)); +// cacheConfigs.put("openisle_categories", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ZERO)); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .build(); + } + + /** + * 配置 RedisTemplate,支持直接操作 Redis + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory, RedisSerializer redisSerializer) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // key 和 hashKey 使用 String 序列化 + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // value 和 hashValue 使用 JSON 序列化 + template.setValueSerializer(redisSerializer); + template.setHashValueSerializer(redisSerializer); + + return template; + } +} diff --git a/backend/src/main/java/com/openisle/model/Tag.java b/backend/src/main/java/com/openisle/model/Tag.java index 6981120c8..ad5d8a981 100644 --- a/backend/src/main/java/com/openisle/model/Tag.java +++ b/backend/src/main/java/com/openisle/model/Tag.java @@ -38,8 +38,8 @@ public class Tag { @Column(nullable = false, updatable = false, columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") private LocalDateTime createdAt; - - @ManyToOne(fetch = FetchType.LAZY) + // 改用redis缓存之后选择立即加载策略 + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "creator_id") private User creator; } diff --git a/backend/src/main/java/com/openisle/service/CategoryService.java b/backend/src/main/java/com/openisle/service/CategoryService.java index beb36c7b4..9486bc88c 100644 --- a/backend/src/main/java/com/openisle/service/CategoryService.java +++ b/backend/src/main/java/com/openisle/service/CategoryService.java @@ -1,8 +1,11 @@ package com.openisle.service; +import com.openisle.config.CachingConfig; import com.openisle.model.Category; import com.openisle.repository.CategoryRepository; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import java.util.List; @@ -11,7 +14,7 @@ import java.util.List; @RequiredArgsConstructor public class CategoryService { private final CategoryRepository categoryRepository; - + @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) public Category createCategory(String name, String description, String icon, String smallIcon) { Category category = new Category(); category.setName(name); @@ -20,7 +23,7 @@ public class CategoryService { category.setSmallIcon(smallIcon); return categoryRepository.save(category); } - + @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) public Category updateCategory(Long id, String name, String description, String icon, String smallIcon) { Category category = categoryRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Category not found")); @@ -38,7 +41,7 @@ public class CategoryService { } return categoryRepository.save(category); } - + @CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true) public void deleteCategory(Long id) { categoryRepository.deleteById(id); } @@ -48,6 +51,14 @@ public class CategoryService { .orElseThrow(() -> new IllegalArgumentException("Category not found")); } + /** + * 该方法每次首页加载都会访问,加入缓存 + * @return + */ + @Cacheable( + value = CachingConfig.CATEGORY_CACHE_NAME, + key = "'listCategories:'" + ) public List listCategories() { return categoryRepository.findAll(); } diff --git a/backend/src/main/java/com/openisle/service/TagService.java b/backend/src/main/java/com/openisle/service/TagService.java index 0f7c51abc..eee84121e 100644 --- a/backend/src/main/java/com/openisle/service/TagService.java +++ b/backend/src/main/java/com/openisle/service/TagService.java @@ -1,9 +1,12 @@ package com.openisle.service; +import com.openisle.config.CachingConfig; import com.openisle.model.Tag; import com.openisle.model.User; import com.openisle.repository.TagRepository; import com.openisle.repository.UserRepository; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import lombok.RequiredArgsConstructor; @@ -18,6 +21,7 @@ public class TagService { private final TagValidator tagValidator; private final UserRepository userRepository; + @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved, String creatorUsername) { tagValidator.validate(name); Tag tag = new Tag(); @@ -42,6 +46,7 @@ public class TagService { return createTag(name, description, icon, smallIcon, true, null); } + @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) { Tag tag = tagRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Tag not found")); @@ -61,6 +66,7 @@ public class TagService { return tagRepository.save(tag); } + @CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true) public void deleteTag(Long id) { tagRepository.deleteById(id); } @@ -85,10 +91,20 @@ public class TagService { return tagRepository.findByApprovedTrue(); } + /** + * 该方法每次首页加载都会访问,加入缓存 + * @param keyword + * @return + */ + @Cacheable( + value = CachingConfig.TAG_CACHE_NAME, + key = "'searchTags:' + (#keyword ?: '')"//keyword为null的场合返回空 + ) public List searchTags(String keyword) { if (keyword == null || keyword.isBlank()) { return tagRepository.findByApprovedTrue(); } + return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword); } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 9c065cb28..9f3d03125 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -9,6 +9,11 @@ spring.datasource.username=${MYSQL_USER:root} spring.datasource.password=${MYSQL_PASSWORD:password} spring.jpa.hibernate.ddl-auto=update +# for redis +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.database=0 + # for jwt app.jwt.secret=${JWT_SECRET:jwt_sec} app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec} diff --git a/backend/src/main/resources/db/init/init_script.sql b/backend/src/main/resources/db/init/init_script.sql index 64e351cba..6f87a3e63 100644 --- a/backend/src/main/resources/db/init/init_script.sql +++ b/backend/src/main/resources/db/init/init_script.sql @@ -33,10 +33,11 @@ CREATE TABLE IF NOT EXISTS `users` ( -- 清空users表 DELETE FROM `users`; -- 插入用户,两个普通用户,一个管理员 +-- username:admin/user1/user2 password:123321 INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES - (1, b'1', '', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'ADMIN', '管理员', NULL, b'1'), - (2, b'1', '', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', '普通用户2', NULL, b'1'), - (3, b'1', '', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 40, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', '普通用户1', NULL, b'1'); + (1, b'1', '', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'ADMIN', 'admin', NULL, b'1'), + (2, b'1', '', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user1', NULL, b'1'), + (3, b'1', '', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', NULL, 40, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user2', NULL, b'1'); -- 创建 tags 表(如果不存在) CREATE TABLE IF NOT EXISTS `tags` (