diff --git a/backend/build.gradle b/backend/build.gradle index a7739985..e9cdb512 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -97,6 +97,11 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis:2.3.1.RELEASE' + // LocalDataTime 역직렬화 문제 해결 패키지 + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'com.fasterxml.jackson.core:jackson-databind' + + } tasks.named('test') { diff --git a/backend/src/main/java/com/graphy/backend/domain/comment/dto/response/GetCommentWithMaskingResponse.java b/backend/src/main/java/com/graphy/backend/domain/comment/dto/response/GetCommentWithMaskingResponse.java index f110413c..762a5f80 100644 --- a/backend/src/main/java/com/graphy/backend/domain/comment/dto/response/GetCommentWithMaskingResponse.java +++ b/backend/src/main/java/com/graphy/backend/domain/comment/dto/response/GetCommentWithMaskingResponse.java @@ -1,7 +1,10 @@ package com.graphy.backend.domain.comment.dto.response; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import com.querydsl.core.annotations.QueryProjection; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -14,6 +17,9 @@ public class GetCommentWithMaskingResponse { private String content; private Long commentId; + + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) private LocalDateTime createdAt; private String nickname; private Long childCount; diff --git a/backend/src/main/java/com/graphy/backend/domain/member/dto/response/GetMemberResponse.java b/backend/src/main/java/com/graphy/backend/domain/member/dto/response/GetMemberResponse.java index 360e249d..ec129461 100644 --- a/backend/src/main/java/com/graphy/backend/domain/member/dto/response/GetMemberResponse.java +++ b/backend/src/main/java/com/graphy/backend/domain/member/dto/response/GetMemberResponse.java @@ -3,11 +3,13 @@ import com.graphy.backend.domain.member.domain.Member; import lombok.*; +import java.io.Serializable; + @Getter @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor -public class GetMemberResponse { +public class GetMemberResponse implements Serializable { private String nickname; private String email; diff --git a/backend/src/main/java/com/graphy/backend/domain/project/controller/ProjectController.java b/backend/src/main/java/com/graphy/backend/domain/project/controller/ProjectController.java index 7534afc5..acce67dd 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/controller/ProjectController.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/controller/ProjectController.java @@ -104,6 +104,13 @@ public ResponseEntity projectDetails(@PathVariable Long projectI .body(ResultResponse.of(ResultCode.PROJECT_GET_SUCCESS, result)); } + @Operation(summary = "findProjectRank", description = "프로젝트 랭킹 조회") + @GetMapping("/rank") + public ResponseEntity projectRankList() { + List result = projectService.findTopRankingProjectList(); + return ResponseEntity.ok(ResultResponse.of(ResultCode.PROJECT_GET_SUCCESS, result)); + } + @Operation(summary = "getProjectPlan", description = "프로젝트 고도화 계획 제안") @PostMapping("/plans") public ResponseEntity projectPlanDetails(final @RequestBody GetProjectPlanRequest getPlanRequest, @CurrentUser Member loginUser) throws ExecutionException, InterruptedException { diff --git a/backend/src/main/java/com/graphy/backend/domain/project/domain/Project.java b/backend/src/main/java/com/graphy/backend/domain/project/domain/Project.java index 0283ea07..160322be 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/domain/Project.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/domain/Project.java @@ -2,6 +2,7 @@ import com.graphy.backend.domain.comment.domain.Comment; import com.graphy.backend.domain.member.domain.Member; +import com.graphy.backend.domain.project.dto.request.UpdateProjectRequest; import com.graphy.backend.global.common.BaseEntity; import lombok.AllArgsConstructor; import lombok.Builder; @@ -61,13 +62,11 @@ public class Project extends BaseEntity { @ColumnDefault("0") private int viewCount = 0; - public void updateProject(String projectName, String content, - String description, Tags tags, - String thumbNail) { - this.projectName = projectName; - this.content = content; - this.description = description; - this.thumbNail = thumbNail; + public void updateProject(UpdateProjectRequest dto, Tags tags) { + this.projectName = dto.getProjectName(); + this.content = dto.getContent(); + this.description = dto.getDescription(); + this.thumbNail = dto.getThumbNail(); projectTags.clear(); addTag(tags); } diff --git a/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectDetailResponse.java b/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectDetailResponse.java index 4e08a99d..ed952949 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectDetailResponse.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectDetailResponse.java @@ -1,21 +1,29 @@ package com.graphy.backend.domain.project.dto.response; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import com.graphy.backend.domain.comment.dto.response.GetCommentWithMaskingResponse; import com.graphy.backend.domain.member.dto.response.GetMemberResponse; import com.graphy.backend.domain.project.domain.Project; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.io.Serializable; import java.time.LocalDateTime; import java.util.List; -import static com.graphy.backend.domain.member.dto.response.GetMemberResponse.*; +import static com.graphy.backend.domain.member.dto.response.GetMemberResponse.from; @Getter @Builder @NoArgsConstructor @AllArgsConstructor -public class GetProjectDetailResponse { +public class GetProjectDetailResponse implements Serializable { private Long id; @@ -29,6 +37,8 @@ public class GetProjectDetailResponse { private List commentsList; + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) private LocalDateTime createdAt; private List techTags; diff --git a/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectRankingResponse.java b/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectRankingResponse.java new file mode 100644 index 00000000..52887b93 --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/domain/project/dto/response/GetProjectRankingResponse.java @@ -0,0 +1,27 @@ +package com.graphy.backend.domain.project.dto.response; + +import com.graphy.backend.domain.project.domain.Project; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GetProjectRankingResponse { + private Long id; + private String projectName; + private int viewCount; + private int likeCount; + + public static GetProjectRankingResponse from(Project project) { + return GetProjectRankingResponse.builder() + .id(project.getId()) + .projectName(project.getProjectName()) + .viewCount(project.getViewCount()) + .likeCount(project.getLikeCount()) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/graphy/backend/domain/project/service/ProjectService.java b/backend/src/main/java/com/graphy/backend/domain/project/service/ProjectService.java index 2b9d2c8f..1ec03503 100644 --- a/backend/src/main/java/com/graphy/backend/domain/project/service/ProjectService.java +++ b/backend/src/main/java/com/graphy/backend/domain/project/service/ProjectService.java @@ -25,14 +25,21 @@ import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -50,6 +57,14 @@ public class ProjectService { private final TagService tagService; private final GPTChatRestService gptChatRestService; private final TagRepository tagRepository; + private final RedisTemplate redisTemplate; + private final RedisTemplate redisRankingTemplate; + private final String RANKING_KEY = "ranking"; + private final String TOP_RANKING_PROJECT_KEY = "topRankingProject"; + private final int START_RANKING = 0; + private final int END_RANKING = 9; + private int NEXT_RANKING = END_RANKING; + // @PostConstruct // public void initTag() throws IOException { @@ -81,6 +96,7 @@ public CreateProjectResponse addProject(CreateProjectRequest dto, Member loginUs public void removeProject(Long projectId) { try { projectRepository.deleteById(projectId); + if (isProjectIdExistInRanking(projectId)) addNextRankingProject(projectId); } catch (EmptyResultDataAccessException e) { throw new EmptyResultException(ErrorCode.PROJECT_DELETED_OR_NOT_EXIST); } @@ -88,11 +104,12 @@ public void removeProject(Long projectId) { @Transactional public UpdateProjectResponse modifyProject(Long projectId, UpdateProjectRequest dto) { - Project project = projectRepository.findById(projectId).get(); + Project project = projectRepository.findById(projectId).orElseThrow(() -> new EmptyResultException(ErrorCode.PROJECT_DELETED_OR_NOT_EXIST)); projectTagService.removeProjectTag(project.getId()); Tags updatedTags = tagService.findTagListByName(dto.getTechTags()); - project.updateProject(dto.getProjectName(), dto.getContent(), dto.getDescription(), updatedTags, dto.getThumbNail()); + project.updateProject(dto, updatedTags); + if (isProjectIdExistInRanking(projectId)) modifyProjectInRanking(project); return UpdateProjectResponse.from(project); } @@ -114,6 +131,7 @@ public Cookie addViewCount(HttpServletRequest request, Long projectId) { if (!oldCookie.getValue().contains("[" + projectId + "]")) { oldCookie.setValue(oldCookie.getValue() + "[" + projectId + "]"); project.addViewCount(); + redisTemplate.opsForZSet().incrementScore(RANKING_KEY, projectId, 1); } oldCookie.setPath("/"); return oldCookie; @@ -121,6 +139,7 @@ public Cookie addViewCount(HttpServletRequest request, Long projectId) { Cookie newCookie = new Cookie("View_Count", "[" + projectId + "]"); newCookie.setPath("/"); project.addViewCount(); + redisTemplate.opsForZSet().incrementScore(RANKING_KEY, projectId, 1); return newCookie; } } @@ -203,4 +222,68 @@ public String getPrompt(final GetProjectPlanRequest request){ return techStacks + "를 이용해" + request.getTopic() +"를 개발 중이고, 현재" + features + "까지 기능 구현한 상태에서 고도화된 기능과 " + plans + "을 사용한 고도화 방안을 알려줘"; } + + public List findTopRankingProjectList() { + HashOperations hashOperation = redisRankingTemplate.opsForHash(); + List topRankingProjectIdList = getTopRankingProjectIdList(); + + return topRankingProjectIdList.stream() + .map(projectId -> hashOperation.get(TOP_RANKING_PROJECT_KEY, projectId)) + .collect(Collectors.toList()); + } + + + @Scheduled(cron = "0 0 6 ? * MON", zone = "Asia/Seoul") + public void initializeProjectRanking() { + NEXT_RANKING = END_RANKING; + HashOperations hashOperation = redisRankingTemplate.opsForHash(); + List topRankingProjectList = getRankedProjectListById(getTopRankingProjectIdList()); + + topRankingProjectList.forEach(project -> hashOperation.put(TOP_RANKING_PROJECT_KEY, project.getId(), project)); + redisRankingTemplate.expire(TOP_RANKING_PROJECT_KEY, 7, TimeUnit.DAYS); + } + + private List getTopRankingProjectIdList() { + ZSetOperations zSetOperations = redisTemplate.opsForZSet(); + return new ArrayList<>(Objects.requireNonNull(zSetOperations.reverseRange(RANKING_KEY, START_RANKING, END_RANKING))); + } + + private List getRankedProjectListById(List projectIds) { + List projectList = projectRepository.findAllById(projectIds); + + return projectList.stream() + .map(project -> { + List comments = commentService.findCommentListWithMasking(project.getId()); + return GetProjectDetailResponse.of(project, comments); + }) + .collect(Collectors.toList()); + } + + private void addNextRankingProject(Long deletedProjectId) { + ZSetOperations zSetOperations = redisTemplate.opsForZSet(); + HashOperations hashOperation = redisRankingTemplate.opsForHash(); + + hashOperation.delete(TOP_RANKING_PROJECT_KEY, deletedProjectId); + zSetOperations.remove(RANKING_KEY, deletedProjectId); + + Long nextRankingProjectId = Objects.requireNonNull(zSetOperations.reverseRange(RANKING_KEY, NEXT_RANKING, NEXT_RANKING)) + .iterator() + .next(); + NEXT_RANKING++; + + GetProjectDetailResponse nextRankingProject = this.findProjectById(nextRankingProjectId); + hashOperation.put(TOP_RANKING_PROJECT_KEY, nextRankingProjectId, nextRankingProject); + } + + private void modifyProjectInRanking(Project project) { + HashOperations hashOperation = redisRankingTemplate.opsForHash(); + List comments = commentService.findCommentListWithMasking(project.getId()); + GetProjectDetailResponse updateProject = GetProjectDetailResponse.of(project, comments); + hashOperation.put(TOP_RANKING_PROJECT_KEY, project.getId(), updateProject); + } + + private boolean isProjectIdExistInRanking(Long projectId) { + HashOperations hashOperation = redisRankingTemplate.opsForHash(); + return hashOperation.hasKey(TOP_RANKING_PROJECT_KEY, projectId); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/graphy/backend/global/config/CacheConfig.java b/backend/src/main/java/com/graphy/backend/global/config/CacheConfig.java new file mode 100644 index 00000000..f7e8e2cf --- /dev/null +++ b/backend/src/main/java/com/graphy/backend/global/config/CacheConfig.java @@ -0,0 +1,9 @@ +package com.graphy.backend.global.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Configuration; + +@EnableCaching +@Configuration +public class CacheConfig { +} diff --git a/backend/src/main/java/com/graphy/backend/global/config/RedisConfig.java b/backend/src/main/java/com/graphy/backend/global/config/RedisConfig.java index 84315235..b2be364b 100644 --- a/backend/src/main/java/com/graphy/backend/global/config/RedisConfig.java +++ b/backend/src/main/java/com/graphy/backend/global/config/RedisConfig.java @@ -1,10 +1,19 @@ package com.graphy.backend.global.config; + +import com.graphy.backend.domain.project.dto.response.GetProjectDetailResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +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.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; @EnableRedisRepositories @Configuration @@ -20,4 +29,37 @@ public class RedisConfig { public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(redisHost, redisPort); } + + @Bean + public RedisTemplate redisProjectRankingTemplate(RedisConnectionFactory redisConnectionFactory){ + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Long.class)); + return redisTemplate; + } + + @Bean + public RedisTemplate redisTopRankingProjectTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(GetProjectDetailResponse.class)); + return redisTemplate; + } + + @Bean + public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) { + RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext + .SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext + .SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); + + return RedisCacheManager + .RedisCacheManagerBuilder + .fromConnectionFactory(redisConnectionFactory) + .cacheDefaults(redisCacheConfiguration) + .build(); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/graphy/backend/global/config/SecurityConfig.java b/backend/src/main/java/com/graphy/backend/global/config/SecurityConfig.java index fba011cf..82479128 100644 --- a/backend/src/main/java/com/graphy/backend/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/graphy/backend/global/config/SecurityConfig.java @@ -39,6 +39,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws "/api/v1/auth/signin", "/api/v1/auth/logout", "/api/v1/projects", + "/api/v1/projects/rank", "/api/v1/members/**", "/swagger-ui/**").permitAll() .antMatchers(HttpMethod.GET, "/api/v1/projects/{projectId}").permitAll() diff --git a/backend/src/test/java/com/graphy/backend/domain/project/service/ProjectServiceTest.java b/backend/src/test/java/com/graphy/backend/domain/project/service/ProjectServiceTest.java index 6bb0d6c7..a62a99f7 100644 --- a/backend/src/test/java/com/graphy/backend/domain/project/service/ProjectServiceTest.java +++ b/backend/src/test/java/com/graphy/backend/domain/project/service/ProjectServiceTest.java @@ -11,10 +11,7 @@ import com.graphy.backend.domain.project.dto.request.CreateProjectRequest; import com.graphy.backend.domain.project.dto.request.GetProjectsRequest; import com.graphy.backend.domain.project.dto.request.UpdateProjectRequest; -import com.graphy.backend.domain.project.dto.response.CreateProjectResponse; -import com.graphy.backend.domain.project.dto.response.GetProjectInfoResponse; -import com.graphy.backend.domain.project.dto.response.GetProjectResponse; -import com.graphy.backend.domain.project.dto.response.UpdateProjectResponse; +import com.graphy.backend.domain.project.dto.response.*; import com.graphy.backend.domain.project.repository.ProjectRepository; import com.graphy.backend.global.common.dto.PageRequest; import com.graphy.backend.global.error.ErrorCode; @@ -30,6 +27,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; import java.util.ArrayList; import java.util.Arrays; @@ -57,6 +56,12 @@ class ProjectServiceTest extends MockTest { @Mock private CustomUserDetailsService customUserDetailsService; + @Mock + private RedisTemplate redisRankingTemplate; // RedisTemplate 모킹 + + @Mock + HashOperations hashOperations; + @InjectMocks private ProjectService projectService; @@ -86,14 +91,15 @@ void updateProject() throws Exception { Tag tag1 = Tag.builder().tech("Vue").build(); Tag tag2 = Tag.builder().tech("Java").build(); - //when when(projectRepository.findById(project.getId())).thenReturn(Optional.of(project)); when(tagService.findTagListByName(techTags)).thenReturn(new Tags(List.of(tag1, tag2))); + when(redisRankingTemplate.opsForHash()).thenReturn(hashOperations); UpdateProjectResponse result = projectService.modifyProject(project.getId(), request); - assertThat(result.getProjectName()).isEqualTo(project.getProjectName()); + //then + assertThat(result.getProjectName()).isEqualTo("afterUpdate"); // 수정된 부분 assertThat(result.getDescription()).isEqualTo(project.getDescription()); assertThat(result.getThumbNail()).isEqualTo(project.getThumbNail()); assertThat(result.getTechTags()).isEqualTo(new ArrayList<>(Arrays.asList("Vue", "Java"))); @@ -177,6 +183,7 @@ void getProjects() throws Exception { @DisplayName("프로젝트 삭제") void deleteProject() throws Exception { //when + when(redisRankingTemplate.opsForHash()).thenReturn(hashOperations); projectService.removeProject(1L); //then