diff --git a/src/main/java/org/myteam/server/board/repository/BoardQueryRepository.java b/src/main/java/org/myteam/server/board/repository/BoardQueryRepository.java index 7a010bf3..5d2e7b60 100644 --- a/src/main/java/org/myteam/server/board/repository/BoardQueryRepository.java +++ b/src/main/java/org/myteam/server/board/repository/BoardQueryRepository.java @@ -40,6 +40,7 @@ import org.myteam.server.global.util.redis.CommonCountDto; import org.myteam.server.global.util.redis.ServiceType; import org.myteam.server.global.util.redis.service.RedisCountService; +import org.myteam.server.global.util.redis.service.RedisService; import org.myteam.server.home.dto.HotBoardDto; import org.myteam.server.home.dto.NewBoardDto; import org.myteam.server.report.domain.DomainType; @@ -57,6 +58,7 @@ public class BoardQueryRepository { private final JPAQueryFactory queryFactory; private final RedisBoardRankingReader rankingReader; private final RedisCountService redisCountService; + private final RedisService redisService; /** * 게시글 목록 조회 @@ -488,6 +490,33 @@ public List getHotBoardList() { return hotBoardList; } + public List zSetGetHotBoardList(){ + List boardId=redisService.getBoardRecommendRankPerDay(); + List hotBoardList = queryFactory + .select(Projections.fields(HotBoardDto.class, + board.boardType, + board.categoryType, + board.id, + board.title, + board.id.in(boardId).as("isHot"), + board.id.in(getNewBoardIdList()).as("isNew"), + new CaseBuilder() + .when(board.thumbnail.isNotEmpty()).then(true) + .otherwise(false) + .as("isImage") + )) + .from(board) + .where(board.id.in(boardId)) + .fetch(); + Map orderMap = IntStream.range(0, boardId.size()) + .boxed() + .collect(Collectors.toMap(boardId::get, i -> i)); + hotBoardList.sort(Comparator.comparingInt(dto -> orderMap.getOrDefault(dto.getId(), Integer.MAX_VALUE))); + // 순위 부여 + AtomicInteger rankCounter = new AtomicInteger(1); + hotBoardList.forEach(dto -> dto.setRank(rankCounter.getAndIncrement())); + return hotBoardList; + } public Long findPreviousBoardId(Long boardId, Category boardType, CategoryType categoryType) { return queryFactory diff --git a/src/main/java/org/myteam/server/comment/controller/CommentController.java b/src/main/java/org/myteam/server/comment/controller/CommentController.java index b63c9ae8..edca3477 100644 --- a/src/main/java/org/myteam/server/comment/controller/CommentController.java +++ b/src/main/java/org/myteam/server/comment/controller/CommentController.java @@ -1,5 +1,6 @@ package org.myteam.server.comment.controller; +import static org.myteam.server.comment.dto.response.CommentResponse.*; import static org.myteam.server.global.web.response.ResponseStatus.SUCCESS; import io.swagger.v3.oas.annotations.Operation; @@ -14,6 +15,7 @@ import org.myteam.server.comment.dto.request.CommentRequest.CommentDeleteRequest; import org.myteam.server.comment.dto.request.CommentRequest.CommentListRequest; import org.myteam.server.comment.dto.request.CommentRequest.CommentSaveRequest; +import org.myteam.server.comment.dto.response.CommentResponse; import org.myteam.server.comment.dto.response.CommentResponse.BestCommentSaveListResponse; import org.myteam.server.comment.dto.response.CommentResponse.CommentSaveListResponse; import org.myteam.server.comment.dto.response.CommentResponse.CommentSaveResponse; @@ -33,6 +35,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @RestController @RequestMapping("/api/comments") @RequiredArgsConstructor @@ -169,4 +173,19 @@ public ResponseEntity> getBestComments( bestComments )); } + @Operation(summary = "최신 댓글 조회", description = "최신 댓글들을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "최신 댓글 목록 조회 성공"), + @ApiResponse(responseCode = "404", description = "게시글이 존재하지 않음", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/latest") + public ResponseEntity>> getLatestComments() { + List + latestCommentListResponse=commentReadService.getLatestComments(); + return ResponseEntity.ok(new ResponseDto<>( + SUCCESS.name(), + "베스트 댓글 목록 조회 성공", + latestCommentListResponse + )); + } } diff --git a/src/main/java/org/myteam/server/comment/dto/response/CommentResponse.java b/src/main/java/org/myteam/server/comment/dto/response/CommentResponse.java index d2cad2b4..1e11bff2 100644 --- a/src/main/java/org/myteam/server/comment/dto/response/CommentResponse.java +++ b/src/main/java/org/myteam/server/comment/dto/response/CommentResponse.java @@ -3,6 +3,8 @@ import java.time.LocalDateTime; import java.util.List; import java.util.UUID; + +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,6 +12,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.myteam.server.comment.domain.Comment; +import org.myteam.server.comment.domain.CommentType; import org.myteam.server.global.page.response.PageCustomResponse; import org.myteam.server.util.ClientUtils; @@ -122,4 +125,12 @@ public static BestCommentSaveListResponse createResponse( .build(); } } + + @AllArgsConstructor + public static class LatestCommentListResponse{ + private Long commentId; + @Schema(description = "최신댓글이 어떤 게시글에 대한 댓글인지를 보여줍니다.") + private CommentType commentType; + private String content; + } } diff --git a/src/main/java/org/myteam/server/comment/repository/CommentQueryRepository.java b/src/main/java/org/myteam/server/comment/repository/CommentQueryRepository.java index 7338139f..11acb314 100644 --- a/src/main/java/org/myteam/server/comment/repository/CommentQueryRepository.java +++ b/src/main/java/org/myteam/server/comment/repository/CommentQueryRepository.java @@ -9,6 +9,7 @@ import static org.myteam.server.comment.domain.QInquiryComment.inquiryComment; import static org.myteam.server.comment.domain.QNewsComment.newsComment; import static org.myteam.server.comment.domain.QNoticeComment.noticeComment; +import static org.myteam.server.comment.dto.response.CommentResponse.*; import static org.myteam.server.improvement.domain.QImprovement.improvement; import static org.myteam.server.inquiry.domain.QInquiry.inquiry; import static org.myteam.server.member.entity.QMember.member; @@ -45,6 +46,7 @@ import org.myteam.server.comment.domain.QMatchComment; import org.myteam.server.comment.domain.QNewsComment; import org.myteam.server.comment.domain.QNoticeComment; +import org.myteam.server.comment.dto.response.CommentResponse; import org.myteam.server.comment.dto.response.CommentResponse.BestCommentResponse; import org.myteam.server.comment.dto.response.CommentResponse.CommentSaveResponse; import org.myteam.server.comment.service.CommentRecommendReadService; @@ -69,6 +71,20 @@ public class CommentQueryRepository { private final CommentRepository commentRepository; private final CommentRecommendReadService commentRecommendReadService; + public List getNewestComment(){ + List comments=queryFactory + .select(Projections.constructor(LatestCommentListResponse.class, + comment1.id, + comment1.commentType, + comment1.comment.substring(0,20))) + .from(comment1) + .orderBy(comment1.createDate.desc()) + .limit(10) + .fetch(); + + return comments; + } + /** * 대댓글 목록 조회 */ diff --git a/src/main/java/org/myteam/server/comment/service/CommentReadService.java b/src/main/java/org/myteam/server/comment/service/CommentReadService.java index 04a6b3d0..b279e042 100644 --- a/src/main/java/org/myteam/server/comment/service/CommentReadService.java +++ b/src/main/java/org/myteam/server/comment/service/CommentReadService.java @@ -1,11 +1,13 @@ package org.myteam.server.comment.service; +import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.myteam.server.comment.domain.Comment; import org.myteam.server.comment.domain.CommentType; import org.myteam.server.comment.dto.request.CommentRequest.CommentListRequest; +import org.myteam.server.comment.dto.response.CommentResponse; import org.myteam.server.comment.dto.response.CommentResponse.BestCommentResponse; import org.myteam.server.comment.dto.response.CommentResponse.BestCommentSaveListResponse; import org.myteam.server.comment.dto.response.CommentResponse.CommentSaveListResponse; @@ -25,6 +27,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static org.myteam.server.comment.dto.response.CommentResponse.*; + @Slf4j @Service @RequiredArgsConstructor @@ -36,6 +40,9 @@ public class CommentReadService { private final SecurityReadService securityReadService; private final CommentRecommendReadService commentRecommendReadService; + public List getLatestComments(){ + return commentQueryRepository.getNewestComment(); + } public Comment findById(Long commentId) { return commentRepository.findById(commentId) .orElseThrow(() -> new PlayHiveException(ErrorCode.COMMENT_NOT_FOUND)); diff --git a/src/main/java/org/myteam/server/global/util/redis/service/RedisService.java b/src/main/java/org/myteam/server/global/util/redis/service/RedisService.java index 74c6ad98..91427b99 100644 --- a/src/main/java/org/myteam/server/global/util/redis/service/RedisService.java +++ b/src/main/java/org/myteam/server/global/util/redis/service/RedisService.java @@ -1,10 +1,18 @@ package org.myteam.server.global.util.redis.service; +import java.sql.Date; import java.time.Duration; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; import org.myteam.server.admin.utill.StaticDataType; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -16,6 +24,7 @@ public class RedisService { // TODO: RedisReportService 로 변경. private final RedisTemplate redisTemplate; + private static final String BOARD_RANK_KEY=":BoardRank"; private static final String ADMIN_ALARM_KEY="ADMIN_ALARM"; private static final int ADMIN_LOGIN_MAX_REQUESTS=10; private static final int MAX_REQUESTS = 3; // 제한 횟수 (기본값: 5분 동안 3회) @@ -91,6 +100,32 @@ public void adminReadCheckUpdate(String adminIdentifier, StaticDataType staticDa redisTemplate.expire(redisKey, Duration.ofDays(30L)); } } + + public void boardRecommendRankPerDay(Long contentId,Long delta){ + LocalDateTime now = LocalDateTime.now().with(LocalTime.MIDNIGHT); + String key = now+BOARD_RANK_KEY; + LocalDateTime nextMidnight = now.plusDays(1).with(LocalTime.MIDNIGHT); + redisTemplate.opsForZSet().incrementScore(key,contentId.toString(),delta); + redisTemplate.expireAt(key,Date.valueOf(nextMidnight.toLocalDate())); + } + public List getBoardRecommendRankPerDay(){ + LocalDateTime now=LocalDateTime.now().with(LocalTime.MIDNIGHT); + String key=now+BOARD_RANK_KEY; + List ids=redisTemplate.opsForZSet().reverseRangeWithScores(key,0,9) + .stream() + .filter(x->{ + if(x.getScore()>0){ + return true; + } + return false; + }) + .map(x->{ + return Long.parseLong(x.getValue()); + }) + .collect(Collectors.toList()); + return ids; + } + /** * 요청 제한을 적용할 Redis Key 생성 * diff --git a/src/main/java/org/myteam/server/recommend/BoardRecommendHandler.java b/src/main/java/org/myteam/server/recommend/BoardRecommendHandler.java index ea4d7537..93073693 100644 --- a/src/main/java/org/myteam/server/recommend/BoardRecommendHandler.java +++ b/src/main/java/org/myteam/server/recommend/BoardRecommendHandler.java @@ -9,6 +9,7 @@ import org.myteam.server.board.service.BoardRecommendReadService; import org.myteam.server.global.exception.ErrorCode; import org.myteam.server.global.exception.PlayHiveException; +import org.myteam.server.global.util.redis.service.RedisService; import org.myteam.server.member.entity.Member; import org.myteam.server.report.domain.DomainType; import org.springframework.stereotype.Component; @@ -20,6 +21,7 @@ public class BoardRecommendHandler implements RecommendHandler { private final BoardRecommendReadService boardRecommendReadService; private final BoardRecommendRepository boardRecommendRepository; private final BoardRepository boardRepository; + private final RedisService redisService; @Override public boolean supports(DomainType type) { @@ -39,10 +41,12 @@ public void saveRecommendation(Long contentId, Member member) { .orElseThrow(() -> new PlayHiveException(ErrorCode.BOARD_NOT_FOUND)); BoardRecommend recommend = BoardRecommend.builder().board(board).member(member).build(); boardRecommendRepository.save(recommend); + redisService.boardRecommendRankPerDay(contentId,1L); } @Override public void deleteRecommendation(Long contentId, UUID userId) { boardRecommendRepository.deleteByBoardIdAndMemberPublicId(contentId, userId); + redisService.boardRecommendRankPerDay(contentId,-1L); } } diff --git a/src/test/java/org/myteam/server/redis/GetHotBoardZsetTest.java b/src/test/java/org/myteam/server/redis/GetHotBoardZsetTest.java new file mode 100644 index 00000000..7fc1338a --- /dev/null +++ b/src/test/java/org/myteam/server/redis/GetHotBoardZsetTest.java @@ -0,0 +1,132 @@ +package org.myteam.server.redis; + + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.myteam.server.board.domain.Board; +import org.myteam.server.board.domain.BoardCount; +import org.myteam.server.board.domain.CategoryType; +import org.myteam.server.board.repository.BoardCountRepository; +import org.myteam.server.board.repository.BoardRepository; +import org.myteam.server.global.domain.Category; +import org.myteam.server.global.security.dto.CustomUserDetails; +import org.myteam.server.global.util.redis.RedisCountBulkUpdater; +import org.myteam.server.global.util.redis.ServiceType; +import org.myteam.server.global.util.redis.service.RedisCountService; +import org.myteam.server.global.util.redis.service.RedisService; +import org.myteam.server.member.domain.MemberRole; +import org.myteam.server.member.domain.MemberStatus; +import org.myteam.server.member.domain.MemberType; +import org.myteam.server.member.entity.Member; +import org.myteam.server.member.entity.MemberActivity; +import org.myteam.server.member.repository.MemberActivityRepository; +import org.myteam.server.member.repository.MemberJpaRepository; +import org.myteam.server.recommend.RecommendActionType; +import org.myteam.server.recommend.RecommendService; +import org.myteam.server.report.domain.DomainType; +import org.myteam.server.support.IntegrationTestSupport; +import org.myteam.server.support.TestContainerSupport; +import org.myteam.server.support.TestDriverSupport; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; + +@SpringBootTest +@ActiveProfiles("test") +public class GetHotBoardZsetTest extends TestContainerSupport { + + + @Autowired + private RedisService redisService; + @Autowired + private RedisCountService redisCountService; + + private List members=new ArrayList<>(); + private List boardList=new ArrayList<>(); + + + @BeforeEach + void setting(){ + + for (int i = 0; i < 10; i++) { + Member newMember = Member.builder() + .email("user" + i + "@test.com") + .password("1234") + .tel("010123456" + i) + .nickname("user" + i) + .role(MemberRole.USER) + .type(MemberType.LOCAL) + .publicId(UUID.randomUUID()) + .status(MemberStatus.ACTIVE) + .build(); + memberJpaRepository.save(newMember); + memberActivityRepository.save(new MemberActivity(newMember)); + members.add(newMember); + + } + for(int i=0;10>i;i++){ + Board board=createBoard(members.get(i), Category.BASEBALL, CategoryType.FREE,"제목", + "내용"); + boardList.add(board); + System.out.printf("boardid:%d",board.getId()); + } + } + @Test + @DisplayName("여러 사용자가 순위를 매기고 정합성이 잘지켜지는지 그리고 순위가 양수인 데이터만 잘가져오는지.") + void recommendZsetTest() throws ExecutionException, InterruptedException { + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(5); + CountDownLatch countDownLatch = new CountDownLatch(threadCount); + assertThat(members.size()).isEqualTo(10); + assertThat(boardList.size()).isEqualTo(10); + + for (Member m: members) { + executorService.execute(() -> { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new UsernamePasswordAuthenticationToken( + new CustomUserDetails(m), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + )); + SecurityContextHolder.setContext(context); + try { + boardList.stream().forEach(x->{ + redisCountService.getCommonCount(ServiceType.RECOMMEND, + DomainType.BOARD, x.getId(), null); + + }); + } finally { + SecurityContextHolder.clearContext(); + countDownLatch.countDown(); + } + }); + } + countDownLatch.await(); + List ids=redisService.getBoardRecommendRankPerDay(); + assertThat(ids.size()).isEqualTo(10); + } + + + +} diff --git a/src/test/java/org/myteam/server/support/TestDriverSupport.java b/src/test/java/org/myteam/server/support/TestDriverSupport.java index fdd23074..c9d9cacc 100644 --- a/src/test/java/org/myteam/server/support/TestDriverSupport.java +++ b/src/test/java/org/myteam/server/support/TestDriverSupport.java @@ -38,9 +38,14 @@ import org.myteam.server.match.team.domain.Team; import org.myteam.server.match.team.domain.TeamCategory; import org.myteam.server.match.team.repository.TeamRepository; +import org.myteam.server.member.domain.MemberRole; +import org.myteam.server.member.domain.MemberStatus; +import org.myteam.server.member.domain.MemberType; import org.myteam.server.member.entity.Member; +import org.myteam.server.member.entity.MemberActivity; import org.myteam.server.member.repository.MemberActivityRepository; import org.myteam.server.member.repository.MemberJpaRepository; +import org.myteam.server.member.service.SecurityReadService; import org.myteam.server.news.news.domain.News; import org.myteam.server.news.news.repository.NewsRepository; import org.myteam.server.news.newsCount.domain.NewsCount; @@ -61,9 +66,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.annotation.Transactional; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import java.time.LocalDateTime; +import java.util.UUID; + +import static org.mockito.BDDMockito.given; @SpringBootTest public abstract class TestDriverSupport { @@ -178,6 +187,7 @@ void tearDown() { memberJpaRepository.deleteAllInBatch(); } + protected News createNews(int index, Category category, int count) { News savedNews = newsRepository.save(News.builder() .title("기사타이틀" + index)