diff --git a/backend/src/main/java/com/openisle/config/CachingConfig.java b/backend/src/main/java/com/openisle/config/CachingConfig.java index d3a2c9f49..7497d1024 100644 --- a/backend/src/main/java/com/openisle/config/CachingConfig.java +++ b/backend/src/main/java/com/openisle/config/CachingConfig.java @@ -44,6 +44,8 @@ public class CachingConfig { public static final String VERIFY_CACHE_NAME="openisle_verify"; // 发帖频率限制 public static final String LIMIT_CACHE_NAME="openisle_limit"; + // 用户访问统计 + public static final String VISIT_CACHE_NAME="openisle_visit"; /** * 自定义Redis的序列化器 diff --git a/backend/src/main/java/com/openisle/config/SecurityConfig.java b/backend/src/main/java/com/openisle/config/SecurityConfig.java index 5f74f692d..ca2c930c3 100644 --- a/backend/src/main/java/com/openisle/config/SecurityConfig.java +++ b/backend/src/main/java/com/openisle/config/SecurityConfig.java @@ -6,6 +6,7 @@ import com.openisle.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -26,6 +27,8 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.beans.factory.annotation.Value; + +import java.time.LocalDate; import java.util.List; import jakarta.servlet.FilterChain; @@ -44,6 +47,8 @@ public class SecurityConfig { @Value("${app.website-url}") private String websiteUrl; + private final RedisTemplate redisTemplate; + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -208,7 +213,8 @@ public class SecurityConfig { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) { - userVisitService.recordVisit(auth.getName()); + String key = CachingConfig.VISIT_CACHE_NAME+":"+ LocalDate.now(); + redisTemplate.opsForSet().add(key, auth.getName()); } filterChain.doFilter(request, response); } diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index af37be3e4..002455900 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -155,10 +155,10 @@ public class PostController { if (tagId != null) { tids = java.util.List.of(tagId); } - - if (auth != null) { - userVisitService.recordVisit(auth.getName()); - } +// 只需要在请求的一开始统计一次 +// if (auth != null) { +// userVisitService.recordVisit(auth.getName()); +// } boolean hasCategories = ids != null && !ids.isEmpty(); boolean hasTags = tids != null && !tids.isEmpty(); @@ -195,10 +195,10 @@ public class PostController { if (tagId != null) { tids = java.util.List.of(tagId); } - - if (auth != null) { - userVisitService.recordVisit(auth.getName()); - } +// 只需要在请求的一开始统计一次 +// if (auth != null) { +// userVisitService.recordVisit(auth.getName()); +// } return postService.listPostsByViews(ids, tids, page, pageSize) .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); @@ -223,10 +223,10 @@ public class PostController { if (tagId != null) { tids = java.util.List.of(tagId); } - - if (auth != null) { - userVisitService.recordVisit(auth.getName()); - } +// 只需要在请求的一开始统计一次 +// if (auth != null) { +// userVisitService.recordVisit(auth.getName()); +// } return postService.listPostsByLatestReply(ids, tids, page, pageSize) .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); @@ -251,9 +251,10 @@ public class PostController { if (tagId != null) { tids = java.util.List.of(tagId); } - if (auth != null) { - userVisitService.recordVisit(auth.getName()); - } +// 只需要在请求的一开始统计一次 +// if (auth != null) { +// userVisitService.recordVisit(auth.getName()); +// } return postService.listFeaturedPosts(ids, tids, page, pageSize) .stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); } diff --git a/backend/src/main/java/com/openisle/controller/UserController.java b/backend/src/main/java/com/openisle/controller/UserController.java index 8c26d9f40..f43e39832 100644 --- a/backend/src/main/java/com/openisle/controller/UserController.java +++ b/backend/src/main/java/com/openisle/controller/UserController.java @@ -100,6 +100,7 @@ public class UserController { )); } + // 这个方法似乎没有使用? @PostMapping("/me/signin") @SecurityRequirement(name = "JWT") @Operation(summary = "Daily sign in", description = "Sign in to receive rewards") diff --git a/backend/src/main/java/com/openisle/schdule/UserVisitScheduler.java b/backend/src/main/java/com/openisle/schdule/UserVisitScheduler.java new file mode 100644 index 000000000..c3fb58c50 --- /dev/null +++ b/backend/src/main/java/com/openisle/schdule/UserVisitScheduler.java @@ -0,0 +1,48 @@ +package com.openisle.schdule; + +import com.openisle.config.CachingConfig; +import com.openisle.model.User; +import com.openisle.model.UserVisit; +import com.openisle.repository.UserRepository; +import com.openisle.repository.UserVisitRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import java.time.LocalDate; +import java.util.Set; + +/** + * 执行计划 + * 将每天用户访问落库 + * @author smallclover + * @since 2025-09-09 + */ +@Component +@RequiredArgsConstructor +public class UserVisitScheduler { + private final RedisTemplate redisTemplate; + private final UserRepository userRepository; + private final UserVisitRepository userVisitRepository; + + @Scheduled(cron = "0 5 0 * * ?")// 每天 00:05 执行 + public void persistDailyVisits(){ + LocalDate yesterday = LocalDate.now().minusDays(1); + String key = CachingConfig.VISIT_CACHE_NAME+":"+ yesterday; + Set usernames = redisTemplate.opsForSet().members(key); + if(!CollectionUtils.isEmpty(usernames)){ + for(String username: usernames){ + User user = userRepository.findByUsername(username).orElse(null); + if(user != null){ + UserVisit userVisit = new UserVisit(); + userVisit.setUser(user); + userVisit.setVisitDate(yesterday); + userVisitRepository.save(userVisit); + } + } + redisTemplate.delete(key); + } + } +} diff --git a/backend/src/main/java/com/openisle/service/UserVisitService.java b/backend/src/main/java/com/openisle/service/UserVisitService.java index 3ad8339f9..272c846de 100644 --- a/backend/src/main/java/com/openisle/service/UserVisitService.java +++ b/backend/src/main/java/com/openisle/service/UserVisitService.java @@ -1,15 +1,22 @@ package com.openisle.service; +import com.openisle.config.CachingConfig; import com.openisle.model.User; import com.openisle.model.UserVisit; import com.openisle.repository.UserRepository; import com.openisle.repository.UserVisitRepository; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import java.time.Duration; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; @Service @RequiredArgsConstructor @@ -17,6 +24,8 @@ public class UserVisitService { private final UserVisitRepository userVisitRepository; private final UserRepository userRepository; + private final RedisTemplate redisTemplate; + public boolean recordVisit(String username) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); @@ -30,10 +39,36 @@ public class UserVisitService { }); } + /** + * 统计访问次数,改为从缓存获取/数据库获取 + * @param username + * @return + */ public long countVisits(String username) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - return userVisitRepository.countByUser(user); + + // 如果缓存存在就返回 + String key1 = CachingConfig.VISIT_CACHE_NAME + ":"+LocalDate.now()+":count:"+username; + Integer cached = (Integer) redisTemplate.opsForValue().get(key1); + if(cached != null){ + return cached.longValue(); + } + + // Redis Set 检查今天是否访问 + String todayKey = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now(); + boolean todayVisited = redisTemplate.opsForSet().isMember(todayKey, username); + + Long visitCount = userVisitRepository.countByUser(user); + if (todayVisited) visitCount += 1; + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59); + long secondsUntilEndOfDay = Duration.between(now, endOfDay).getSeconds(); + + // 写入缓存,设置 TTL,当天剩余时间 + redisTemplate.opsForValue().set(key1, visitCount, Duration.ofSeconds(secondsUntilEndOfDay)); + return visitCount; } public long countDau(LocalDate date) {