From 40a2c140e73586d03428fde4b89cfb16de0b4c13 Mon Sep 17 00:00:00 2001 From: myqewr Date: Wed, 20 Aug 2025 21:49:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EA=B7=B8=EB=A3=A8=EB=B0=8D=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EC=A7=80=20-=20=EC=9A=94=EC=A6=98=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=EC=9E=88=EB=8A=94=20=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/post-api.adoc | 14 ++ .../GetUserPickPopularPostsController.java | 31 +++++ .../GetUserPickPopularPostsResponse.java | 34 +++++ .../cache/LoadTrendingPostsTestAdapter.java | 86 ------------ ...dUserPickPopularPostsWithCacheAdapter.java | 54 ++++++++ .../post/PostDomainPersistenceAdapter.java | 25 +++- .../repository/PostRepository.java | 28 ++++ .../repository/UserRepository.java | 5 + .../GetUserPickPopularPostsScheduler.java | 27 ++++ .../user/GetUserPickPopularPostsUseCase.java | 10 ++ .../LoadUserPickPopularWithCachePort.java | 13 ++ .../out/persistence/post/LoadPostPort.java | 3 + .../post/LoadPostWithBookmarkCountPort.java | 4 + .../post/LoadUserForPostDomainPort.java | 12 ++ .../FindBookmarkCountByPostIdsQuery.java | 13 ++ .../query/FindUserPickPopularPostsQuery.java | 15 +++ .../post/GetUserPickPopularPostsService.java | 108 +++++++++++++++ .../vo/post/PostAndBookmarkCountVo.java | 11 ++ .../application/vo/post/UserIdAndNameVo.java | 11 ++ .../vo/post/UserPickPopularPostsVo.java | 44 ++++++ .../server/common/consts/StaticConsts.java | 3 + .../cache/CaffeineCacheConfig.java | 11 ++ .../post/LoadUserPickPopularPostsTest.java | 127 ++++++++++++++++++ 23 files changed, 602 insertions(+), 87 deletions(-) create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/controller/GetUserPickPopularPostsController.java create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/dto/response/GetUserPickPopularPostsResponse.java delete mode 100644 src/main/java/com/ftm/server/adapter/out/cache/LoadTrendingPostsTestAdapter.java create mode 100644 src/main/java/com/ftm/server/adapter/out/cache/LoadUserPickPopularPostsWithCacheAdapter.java create mode 100644 src/main/java/com/ftm/server/adapter/out/scheduler/GetUserPickPopularPostsScheduler.java create mode 100644 src/main/java/com/ftm/server/application/port/in/user/GetUserPickPopularPostsUseCase.java create mode 100644 src/main/java/com/ftm/server/application/port/out/cache/LoadUserPickPopularWithCachePort.java create mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/LoadUserForPostDomainPort.java create mode 100644 src/main/java/com/ftm/server/application/query/FindBookmarkCountByPostIdsQuery.java create mode 100644 src/main/java/com/ftm/server/application/query/FindUserPickPopularPostsQuery.java create mode 100644 src/main/java/com/ftm/server/application/service/post/GetUserPickPopularPostsService.java create mode 100644 src/main/java/com/ftm/server/application/vo/post/PostAndBookmarkCountVo.java create mode 100644 src/main/java/com/ftm/server/application/vo/post/UserIdAndNameVo.java create mode 100644 src/main/java/com/ftm/server/application/vo/post/UserPickPopularPostsVo.java create mode 100644 src/test/java/com/ftm/server/post/LoadUserPickPopularPostsTest.java diff --git a/src/docs/asciidoc/post-api.adoc b/src/docs/asciidoc/post-api.adoc index 4cbc060..92efaa7 100644 --- a/src/docs/asciidoc/post-api.adoc +++ b/src/docs/asciidoc/post-api.adoc @@ -263,3 +263,17 @@ include::{snippetsDir}/loadPostProductHashTags/1/http-response.adoc[] include::{snippetsDir}/loadPostProductHashTags/1/response-fields.adoc[] --- + + +=== **9. 그루밍 라운지 - "요즘 인기있는 글" 조회** + +==== Request +include::{snippetsDir}/loadUserPickPopular/1/http-request.adoc[] + +==== 성공 Response +include::{snippetsDir}/loadUserPickPopular/1/http-response.adoc[] + +==== Response Body Fields +include::{snippetsDir}/loadUserPickPopular/1/response-fields.adoc[] + +--- \ No newline at end of file diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/controller/GetUserPickPopularPostsController.java b/src/main/java/com/ftm/server/adapter/in/web/post/controller/GetUserPickPopularPostsController.java new file mode 100644 index 0000000..f1a8ad4 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/controller/GetUserPickPopularPostsController.java @@ -0,0 +1,31 @@ +package com.ftm.server.adapter.in.web.post.controller; + +import com.ftm.server.adapter.in.web.post.dto.response.GetUserPickPopularPostsResponse; +import com.ftm.server.application.port.in.user.GetUserPickPopularPostsUseCase; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.common.response.enums.SuccessResponseCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Slf4j +@RequiredArgsConstructor +public class GetUserPickPopularPostsController { + + private final GetUserPickPopularPostsUseCase userPickPopularPostsUseCase; + + @GetMapping("/api/posts/userpick/popular") + public ResponseEntity getUserPickPopularPosts() { + List userPickPopularPostsResponses = + userPickPopularPostsUseCase.execute().stream() + .map(GetUserPickPopularPostsResponse::from) + .toList(); + + return ResponseEntity.ok( + ApiResponse.success(SuccessResponseCode.OK, userPickPopularPostsResponses)); + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/GetUserPickPopularPostsResponse.java b/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/GetUserPickPopularPostsResponse.java new file mode 100644 index 0000000..8452798 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/GetUserPickPopularPostsResponse.java @@ -0,0 +1,34 @@ +package com.ftm.server.adapter.in.web.post.dto.response; + +import com.ftm.server.application.vo.post.UserPickPopularPostsVo; +import java.util.List; +import lombok.Data; + +@Data +public class GetUserPickPopularPostsResponse { + + private final Integer ranking; + private final Long postId; + private final String title; + private final Long authorId; + private final String authorName; + private final Integer viewCount; + private final Integer likeCount; + private final Long scrapCount; + private final String imageUrl; + private final List hashtags; + + public static GetUserPickPopularPostsResponse from(UserPickPopularPostsVo vo) { + return new GetUserPickPopularPostsResponse( + vo.getRanking(), + vo.getPostId(), + vo.getTitle(), + vo.getAuthorId(), + vo.getAuthorName(), + vo.getViewCount(), + vo.getLikeCount(), + vo.getScrapCount(), + vo.getImageUrl(), + vo.getHashtags()); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/cache/LoadTrendingPostsTestAdapter.java b/src/main/java/com/ftm/server/adapter/out/cache/LoadTrendingPostsTestAdapter.java deleted file mode 100644 index c7f5483..0000000 --- a/src/main/java/com/ftm/server/adapter/out/cache/LoadTrendingPostsTestAdapter.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.ftm.server.adapter.out.cache; - -import com.ftm.server.application.port.out.cache.LoadTrendingPostsWithCachePort; -import com.ftm.server.application.port.out.persistence.post.LoadPostImagePort; -import com.ftm.server.application.port.out.persistence.post.LoadPostWithBookmarkCountPort; -import com.ftm.server.application.query.FindByIdsQuery; -import com.ftm.server.application.query.FindPostsByCreatedDateQuery; -import com.ftm.server.application.vo.post.PostWithBookmarkCountVo; -import com.ftm.server.application.vo.post.TrendingPostVo; -import java.time.LocalDate; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.IntStream; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Primary; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Primary -public class LoadTrendingPostsTestAdapter implements LoadTrendingPostsWithCachePort { - - private final LoadPostWithBookmarkCountPort loadPostPort; - private final LoadPostImagePort loadPostImagePort; - - private static final int N = 15; - - @Override - public List loadTrendingPosts() { - - // 현재보다 1주일 이전에 작성된 게시물만 조회(북마크 조회수 포함) (예전 게시물은 포함x) - List rawPosts = - loadPostPort.loadAllPostsWithBookmarkCount( - FindPostsByCreatedDateQuery.of(LocalDate.now())); - - if (rawPosts.isEmpty()) return List.of(); - - // 1. 최대값 계산 (정규화를 위해) - long maxView = 1, maxLike = 1, maxScrap = 1; - for (PostWithBookmarkCountVo post : rawPosts) { - maxView = Math.max(maxView, post.getViewCount()); - maxLike = Math.max(maxLike, post.getLikeCount()); - maxScrap = Math.max(maxScrap, post.getScrapCount()); - } - - // 2. 점수 계산 후 정렬 - long finalMaxView = maxView; - long finalMaxLike = maxLike; - long finalMaxScrap = maxScrap; - List topN = - rawPosts.stream() - .sorted( - Comparator.comparingDouble( - p -> - -((double) p.getViewCount() / finalMaxView - + (double) p.getLikeCount() - / finalMaxLike - + (double) p.getScrapCount() - / finalMaxScrap) - / 3.0)) - .limit(N) - .toList(); - - // 3. 각 게시물 이미지 조회 (없으면 null) - List postIds = topN.stream().map(PostWithBookmarkCountVo::getPostId).toList(); - - Map imageMap = new HashMap<>(); - postIds.forEach(p -> imageMap.put(p, null)); - loadPostImagePort - .loadRepresentativeImagesByPostIds(FindByIdsQuery.from(postIds)) - .forEach( - postImage -> imageMap.put(postImage.getPostId(), postImage.getObjectKey())); - - // 4. 결과 조합 (순위 부여) - return IntStream.range(0, topN.size()) - .mapToObj( - i -> { - PostWithBookmarkCountVo post = topN.get(i); - String imageKey = imageMap.get(post.getPostId()); - return TrendingPostVo.from(post, i + 1, imageKey); // rank = i+1 - }) - .toList(); - } -} diff --git a/src/main/java/com/ftm/server/adapter/out/cache/LoadUserPickPopularPostsWithCacheAdapter.java b/src/main/java/com/ftm/server/adapter/out/cache/LoadUserPickPopularPostsWithCacheAdapter.java new file mode 100644 index 0000000..ebf9376 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/cache/LoadUserPickPopularPostsWithCacheAdapter.java @@ -0,0 +1,54 @@ +package com.ftm.server.adapter.out.cache; + +import static com.ftm.server.common.consts.StaticConsts.USER_PICK_POPULAR_POSTS_CACHE_KEY_ALL; +import static com.ftm.server.common.consts.StaticConsts.USER_PICK_POPULAR_POSTS_CACHE_NAME; + +import com.ftm.server.application.port.out.cache.LoadUserPickPopularWithCachePort; +import com.ftm.server.application.port.out.persistence.post.LoadPostPort; +import com.ftm.server.application.query.FindUserPickPopularPostsQuery; +import com.ftm.server.common.annotation.Adapter; +import com.ftm.server.domain.entity.Post; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; + +@RequiredArgsConstructor +@Adapter +@Slf4j +@CacheConfig(cacheManager = "userPickPopularPostsCacheManager") +public class LoadUserPickPopularPostsWithCacheAdapter implements LoadUserPickPopularWithCachePort { + + private final LoadPostPort loadPostPort; + + @Override + @CachePut( + cacheNames = USER_PICK_POPULAR_POSTS_CACHE_NAME, + key = USER_PICK_POPULAR_POSTS_CACHE_KEY_ALL) + public List getUserPickPopularPostCachePut() { + return execute(); + } + + @Override + @Cacheable( + cacheNames = USER_PICK_POPULAR_POSTS_CACHE_NAME, + key = USER_PICK_POPULAR_POSTS_CACHE_KEY_ALL) + public List getUserPickPopularPost() { + return execute(); + } + + public List execute() { + // 최근 1개월 상위 4개 post id를 조회 + LocalDateTime since = LocalDate.now().minusMonths(1).atStartOfDay(); + List postList = + loadPostPort.loadUserPickPopularPosts(FindUserPickPopularPostsQuery.of(since, 4)); + + if (postList.isEmpty()) return List.of(); + + return postList; + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java index dd57aaf..bea5e0d 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java @@ -5,7 +5,9 @@ import com.ftm.server.adapter.out.persistence.repository.*; import com.ftm.server.application.port.out.persistence.post.*; import com.ftm.server.application.query.*; +import com.ftm.server.application.vo.post.PostAndBookmarkCountVo; import com.ftm.server.application.vo.post.PostWithBookmarkCountVo; +import com.ftm.server.application.vo.post.UserIdAndNameVo; import com.ftm.server.application.vo.post.UserWithPostCountVo; import com.ftm.server.common.annotation.Adapter; import com.ftm.server.common.exception.CustomException; @@ -36,7 +38,8 @@ public class PostDomainPersistenceAdapter DeletePostImagePort, DeletePostProductPort, DeletePostProductImagePort, - LoadPostWithBookmarkCountPort { + LoadPostWithBookmarkCountPort, + LoadUserForPostDomainPort { private final PostRepository postRepository; private final PostImageRepository postImageRepository; @@ -148,6 +151,15 @@ public List loadPostsByDeleteOption(FindPostByDeleteOptionQuery query) { .toList(); } + @Override + public List loadUserPickPopularPosts(FindUserPickPopularPostsQuery query) { + return postRepository + .findTopNPostsByViewCountAndLikeCount(query.getSince(), query.getLimit()) + .stream() + .map(postMapper::toDomainEntity) + .toList(); + } + @Override public List loadPostImagesByPostId(FindByPostIdQuery query) { PostJpaEntity postJpaEntity = @@ -301,6 +313,12 @@ public List loadAllPostsWithUserAndBookmarkCount( return postRepository.findAllPostsWithUserAndBookmarkCount(query); } + @Override + public List getPostAndBookmarkCount( + FindBookmarkCountByPostIdsQuery query) { + return postRepository.findBookmarkCountsByPostIds(query.getPostIds()); + } + @Override public List loadRepresentativeImagesByPostIds(FindByIdsQuery query) { return postImageRepository.findRepresentativeImagesByPostIdIn(query).stream() @@ -314,4 +332,9 @@ public List loadPostListByUsers(FindByUserIdsQuery query) { .map(postMapper::toDomainEntity) .toList(); } + + @Override + public List loadPostAndAuthorName(FindByIdsQuery query) { + return userRepository.findUserNameByUserIds(query.getIds()); + } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostRepository.java index b188a15..ce4a9b8 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostRepository.java @@ -1,6 +1,8 @@ package com.ftm.server.adapter.out.persistence.repository; import com.ftm.server.adapter.out.persistence.model.PostJpaEntity; +import com.ftm.server.application.vo.post.PostAndBookmarkCountVo; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -22,4 +24,30 @@ public interface PostRepository @Query("SELECT p FROM PostJpaEntity p WHERE p.user.id IN (:userIds)") List findAllByUserIdIn(@Param("userIds") List userIds); + + @Query( + value = + """ + SELECT * + FROM post p + WHERE p.created_at >= :since + ORDER BY + (p.like_count / NULLIF(MAX(p.like_count) OVER (), 0.0) + + p.view_count / NULLIF(MAX(p.view_count) OVER (), 0.0)) DESC , + p.id DESC + LIMIT :limit + """, + nativeQuery = true) + List findTopNPostsByViewCountAndLikeCount( + @Param("since") LocalDateTime since, @Param("limit") int limit); + + @Query( + """ + SELECT new com.ftm.server.application.vo.post.PostAndBookmarkCountVo(p.id, COUNT(b)) + FROM PostJpaEntity p + LEFT JOIN BookmarkJpaEntity b ON b.post = p + WHERE p.id IN :postIds + GROUP BY p.id + """) + List findBookmarkCountsByPostIds(@Param("postIds") List postIds); } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/UserRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/UserRepository.java index b2e90e1..e596531 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/UserRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/UserRepository.java @@ -1,6 +1,7 @@ package com.ftm.server.adapter.out.persistence.repository; import com.ftm.server.adapter.out.persistence.model.UserJpaEntity; +import com.ftm.server.application.vo.post.UserIdAndNameVo; import com.ftm.server.domain.enums.SocialProvider; import com.ftm.server.domain.enums.UserRole; import java.time.LocalDateTime; @@ -37,4 +38,8 @@ List findAllByDeletedBefore( @Param("isDeleted") Boolean isDeleted, @Param("end") LocalDateTime end); Boolean existsByEmailAndIsDeleted(String email, boolean b); + + @Query( + "SELECT new com.ftm.server.application.vo.post.UserIdAndNameVo(u.id, u.nickname) from UserJpaEntity u where u.id in :userIds") + List findUserNameByUserIds(@Param("userIds") List userIds); } diff --git a/src/main/java/com/ftm/server/adapter/out/scheduler/GetUserPickPopularPostsScheduler.java b/src/main/java/com/ftm/server/adapter/out/scheduler/GetUserPickPopularPostsScheduler.java new file mode 100644 index 0000000..6392c94 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/scheduler/GetUserPickPopularPostsScheduler.java @@ -0,0 +1,27 @@ +package com.ftm.server.adapter.out.scheduler; + +import com.ftm.server.application.port.out.cache.LoadUserPickPopularWithCachePort; +import com.ftm.server.common.annotation.Adapter; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; + +@Adapter +@Slf4j +@RequiredArgsConstructor +public class GetUserPickPopularPostsScheduler { + + private final LoadUserPickPopularWithCachePort loadUserPickPopularWithCachePort; + + // 마지막 실행으로부터 55분 뒤에 재실행됨. + // cache TTL 값인 1시간이 끝나기 이전에 캐시를 업데이트 해 놓는다. + @Scheduled(fixedRateString = "PT55M", initialDelayString = "PT1M") + public void run() { + log.info( + "Loading UserPick Popular Posts at {}", + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + loadUserPickPopularWithCachePort.getUserPickPopularPostCachePut(); + } +} diff --git a/src/main/java/com/ftm/server/application/port/in/user/GetUserPickPopularPostsUseCase.java b/src/main/java/com/ftm/server/application/port/in/user/GetUserPickPopularPostsUseCase.java new file mode 100644 index 0000000..e8bcc4f --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/user/GetUserPickPopularPostsUseCase.java @@ -0,0 +1,10 @@ +package com.ftm.server.application.port.in.user; + +import com.ftm.server.application.vo.post.UserPickPopularPostsVo; +import com.ftm.server.common.annotation.UseCase; +import java.util.List; + +@UseCase +public interface GetUserPickPopularPostsUseCase { + List execute(); +} diff --git a/src/main/java/com/ftm/server/application/port/out/cache/LoadUserPickPopularWithCachePort.java b/src/main/java/com/ftm/server/application/port/out/cache/LoadUserPickPopularWithCachePort.java new file mode 100644 index 0000000..dd3b797 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/cache/LoadUserPickPopularWithCachePort.java @@ -0,0 +1,13 @@ +package com.ftm.server.application.port.out.cache; + +import com.ftm.server.common.annotation.Port; +import com.ftm.server.domain.entity.Post; +import java.util.List; + +@Port +public interface LoadUserPickPopularWithCachePort { + + List getUserPickPopularPost(); + + List getUserPickPopularPostCachePut(); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostPort.java index c59b145..740419b 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostPort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostPort.java @@ -3,6 +3,7 @@ import com.ftm.server.application.query.FindByIdQuery; import com.ftm.server.application.query.FindByUserIdsQuery; import com.ftm.server.application.query.FindPostByDeleteOptionQuery; +import com.ftm.server.application.query.FindUserPickPopularPostsQuery; import com.ftm.server.common.annotation.Port; import com.ftm.server.domain.entity.Post; import java.util.List; @@ -16,4 +17,6 @@ public interface LoadPostPort { List loadPostListByUsers(FindByUserIdsQuery query); List loadPostsByDeleteOption(FindPostByDeleteOptionQuery query); + + List loadUserPickPopularPosts(FindUserPickPopularPostsQuery query); } diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostWithBookmarkCountPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostWithBookmarkCountPort.java index 4d0548a..8bde99c 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostWithBookmarkCountPort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostWithBookmarkCountPort.java @@ -1,6 +1,8 @@ package com.ftm.server.application.port.out.persistence.post; +import com.ftm.server.application.query.FindBookmarkCountByPostIdsQuery; import com.ftm.server.application.query.FindPostsByCreatedDateQuery; +import com.ftm.server.application.vo.post.PostAndBookmarkCountVo; import com.ftm.server.application.vo.post.PostWithBookmarkCountVo; import com.ftm.server.application.vo.post.UserWithPostCountVo; import com.ftm.server.common.annotation.Port; @@ -12,4 +14,6 @@ public interface LoadPostWithBookmarkCountPort { List loadAllPostsWithUserAndBookmarkCount( FindPostsByCreatedDateQuery query); + + List getPostAndBookmarkCount(FindBookmarkCountByPostIdsQuery query); } diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadUserForPostDomainPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadUserForPostDomainPort.java new file mode 100644 index 0000000..c014a34 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadUserForPostDomainPort.java @@ -0,0 +1,12 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.application.query.FindByIdsQuery; +import com.ftm.server.application.vo.post.UserIdAndNameVo; +import com.ftm.server.common.annotation.Port; +import java.util.List; + +@Port +public interface LoadUserForPostDomainPort { + + List loadPostAndAuthorName(FindByIdsQuery query); +} diff --git a/src/main/java/com/ftm/server/application/query/FindBookmarkCountByPostIdsQuery.java b/src/main/java/com/ftm/server/application/query/FindBookmarkCountByPostIdsQuery.java new file mode 100644 index 0000000..bf37053 --- /dev/null +++ b/src/main/java/com/ftm/server/application/query/FindBookmarkCountByPostIdsQuery.java @@ -0,0 +1,13 @@ +package com.ftm.server.application.query; + +import java.util.List; +import lombok.Data; + +@Data +public class FindBookmarkCountByPostIdsQuery { + private final List postIds; + + public static FindBookmarkCountByPostIdsQuery of(List postIds) { + return new FindBookmarkCountByPostIdsQuery(postIds); + } +} diff --git a/src/main/java/com/ftm/server/application/query/FindUserPickPopularPostsQuery.java b/src/main/java/com/ftm/server/application/query/FindUserPickPopularPostsQuery.java new file mode 100644 index 0000000..f7a8a82 --- /dev/null +++ b/src/main/java/com/ftm/server/application/query/FindUserPickPopularPostsQuery.java @@ -0,0 +1,15 @@ +package com.ftm.server.application.query; + +import java.time.LocalDateTime; +import lombok.Data; + +@Data +public class FindUserPickPopularPostsQuery { + + private final LocalDateTime since; + private final Integer limit; + + public static FindUserPickPopularPostsQuery of(LocalDateTime since, Integer limit) { + return new FindUserPickPopularPostsQuery(since, limit); + } +} diff --git a/src/main/java/com/ftm/server/application/service/post/GetUserPickPopularPostsService.java b/src/main/java/com/ftm/server/application/service/post/GetUserPickPopularPostsService.java new file mode 100644 index 0000000..651f50f --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/GetUserPickPopularPostsService.java @@ -0,0 +1,108 @@ +package com.ftm.server.application.service.post; + +import com.ftm.server.application.port.in.user.GetUserPickPopularPostsUseCase; +import com.ftm.server.application.port.out.cache.LoadUserPickPopularWithCachePort; +import com.ftm.server.application.port.out.persistence.post.LoadPostImagePort; +import com.ftm.server.application.port.out.persistence.post.LoadPostWithBookmarkCountPort; +import com.ftm.server.application.port.out.persistence.post.LoadUserForPostDomainPort; +import com.ftm.server.application.query.FindBookmarkCountByPostIdsQuery; +import com.ftm.server.application.query.FindByIdsQuery; +import com.ftm.server.application.vo.post.PostAndBookmarkCountVo; +import com.ftm.server.application.vo.post.UserIdAndNameVo; +import com.ftm.server.application.vo.post.UserPickPopularPostsVo; +import com.ftm.server.common.consts.PropertiesHolder; +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.PostImage; +import com.ftm.server.domain.enums.PostHashtag; +import java.util.Arrays; +import java.util.List; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GetUserPickPopularPostsService implements GetUserPickPopularPostsUseCase { + + private final LoadUserPickPopularWithCachePort loadUserPickPopularWithCachePort; + + private final LoadPostWithBookmarkCountPort loadPostWithBookmarkCountPort; + private final LoadPostImagePort loadPostImagePort; + private final LoadUserForPostDomainPort loadUserForPostDomainPort; + + @Override + public List execute() { + + // 1) 상위 4개 게시물의 id 를 cache 에서 조회 + List postList = loadUserPickPopularWithCachePort.getUserPickPopularPost(); + + // 2) id 목록 + List postIds = postList.stream().map(Post::getId).toList(); + List authorIds = postList.stream().map(Post::getUserId).toList(); + + // 3) 북마크(스크랩) 수 + List postAndBookmarkCountVos = + loadPostWithBookmarkCountPort.getPostAndBookmarkCount( + FindBookmarkCountByPostIdsQuery.of(postIds)); + + var scrapCountMap = + postAndBookmarkCountVos.stream() + .collect( + java.util.stream.Collectors.toMap( + PostAndBookmarkCountVo::getPostId, + PostAndBookmarkCountVo::getBookmarkCount)); + + // 4) 작성자 이름 + List userIdAndNameVos = + loadUserForPostDomainPort.loadPostAndAuthorName(FindByIdsQuery.from(authorIds)); + var authorNameMap = + userIdAndNameVos.stream() + .collect( + java.util.stream.Collectors.toMap( + UserIdAndNameVo::getUserId, + UserIdAndNameVo::getAuthorName, + (a, b) -> a // 중복 authorId 있을 경우 첫 값 사용 + )); + + // 5) 대표 이미지 + List postImages = + loadPostImagePort.loadRepresentativeImagesByPostIds(FindByIdsQuery.from(postIds)); + var imageUrlMap = + postImages.stream() + .collect( + java.util.stream.Collectors.toMap( + PostImage::getPostId, + PostImage::getObjectKey, + (a, b) -> a // 중복 시 첫 이미지 + )); + + // 6) 합치기 (postList 순서 = 랭킹) + return IntStream.range(0, postList.size()) + .mapToObj( + i -> { + Post p = postList.get(i); + int ranking = i + 1; + + long scrapCount = scrapCountMap.getOrDefault(p.getId(), 0L); + String authorName = authorNameMap.getOrDefault(p.getUserId(), ""); + String imageUrl = + imageUrlMap.getOrDefault( + p.getId(), + PropertiesHolder.POST_DEFAULT_IMAGE); // 없으면 null + List hashtags = + p.getHashtags() == null || p.getHashtags().length == 0 + ? List.of() + : Arrays.stream(p.getHashtags()) + .map(PostHashtag::getTag) + .toList(); + return UserPickPopularPostsVo.of( + ranking, + p, + authorName, + scrapCount, + PropertiesHolder.CDN_PATH + "/" + imageUrl, + hashtags); + }) + .toList(); + } +} diff --git a/src/main/java/com/ftm/server/application/vo/post/PostAndBookmarkCountVo.java b/src/main/java/com/ftm/server/application/vo/post/PostAndBookmarkCountVo.java new file mode 100644 index 0000000..6c8947e --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/PostAndBookmarkCountVo.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.vo.post; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class PostAndBookmarkCountVo { + private final Long postId; + private final Long bookmarkCount; +} diff --git a/src/main/java/com/ftm/server/application/vo/post/UserIdAndNameVo.java b/src/main/java/com/ftm/server/application/vo/post/UserIdAndNameVo.java new file mode 100644 index 0000000..23b97c2 --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/UserIdAndNameVo.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.vo.post; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class UserIdAndNameVo { + Long userId; + String authorName; +} diff --git a/src/main/java/com/ftm/server/application/vo/post/UserPickPopularPostsVo.java b/src/main/java/com/ftm/server/application/vo/post/UserPickPopularPostsVo.java new file mode 100644 index 0000000..3a524f8 --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/UserPickPopularPostsVo.java @@ -0,0 +1,44 @@ +package com.ftm.server.application.vo.post; + +import com.ftm.server.domain.entity.Post; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Getter +@AllArgsConstructor +@Slf4j +public class UserPickPopularPostsVo { + + private final Integer ranking; + private final Long postId; + private final String title; + private final Long authorId; + private final String authorName; + private final Integer viewCount; + private final Integer likeCount; + private final Long scrapCount; + private final String imageUrl; + private final List hashtags; + + public static UserPickPopularPostsVo of( + Integer ranking, + Post post, + String authorName, + Long scrapCount, + String imageUrl, + List hashtags) { + return new UserPickPopularPostsVo( + ranking, + post.getId(), + post.getTitle(), + post.getUserId(), + authorName, + post.getViewCount(), + post.getLikeCount(), + scrapCount, + imageUrl, + hashtags); + } +} diff --git a/src/main/java/com/ftm/server/common/consts/StaticConsts.java b/src/main/java/com/ftm/server/common/consts/StaticConsts.java index 433471c..502426b 100644 --- a/src/main/java/com/ftm/server/common/consts/StaticConsts.java +++ b/src/main/java/com/ftm/server/common/consts/StaticConsts.java @@ -18,4 +18,7 @@ public class StaticConsts { public static final String TRENDING_POSTS_CACHE_NAME = "ftm:posts:trend"; public static final String TRENDING_POSTS_CACHE_KEY_ALL = "'all'"; + + public static final String USER_PICK_POPULAR_POSTS_CACHE_NAME = "ftm:posts:userpick:popular"; + public static final String USER_PICK_POPULAR_POSTS_CACHE_KEY_ALL = "'all'"; } diff --git a/src/main/java/com/ftm/server/infrastructure/cache/CaffeineCacheConfig.java b/src/main/java/com/ftm/server/infrastructure/cache/CaffeineCacheConfig.java index d54e334..94d7705 100644 --- a/src/main/java/com/ftm/server/infrastructure/cache/CaffeineCacheConfig.java +++ b/src/main/java/com/ftm/server/infrastructure/cache/CaffeineCacheConfig.java @@ -45,4 +45,15 @@ public CaffeineCacheManager cacheManagerForTrendingUsers() { .recordStats()); return manager; } + + @Bean("userPickPopularPostsCacheManager") // 유저픽 게시글 - 최근 인기있는 게시물 용 캐시 매니저 + public CaffeineCacheManager cacheManagerForUserPickPopularPosts() { + CaffeineCacheManager mgr = new CaffeineCacheManager(); + mgr.setCaffeine( + Caffeine.newBuilder() + .maximumSize(10) + // 1시간에 한번 씩 최신 인기 게시물 갱신 + .expireAfterWrite(1, TimeUnit.HOURS)); + return mgr; + } } diff --git a/src/test/java/com/ftm/server/post/LoadUserPickPopularPostsTest.java b/src/test/java/com/ftm/server/post/LoadUserPickPopularPostsTest.java new file mode 100644 index 0000000..a22d08c --- /dev/null +++ b/src/test/java/com/ftm/server/post/LoadUserPickPopularPostsTest.java @@ -0,0 +1,127 @@ +package com.ftm.server.post; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.ftm.server.BaseTest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostRequest; +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.port.out.persistence.post.SavePostPort; +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.enums.PostHashtag; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +public class LoadUserPickPopularPostsTest extends BaseTest { + + @Autowired private SavePostPort savePostPort; + + private final List responseFields = + List.of( + fieldWithPath("status").type(NUMBER).description("응답 상태"), + fieldWithPath("code").type(STRING).description("상태 코드"), + fieldWithPath("message").type(STRING).description("메시지"), + fieldWithPath("data") + .type(ARRAY) + .optional() + .description("응답 데이터 : 대상 게시물이 없는 경우 빈 배열"), + fieldWithPath("data[].ranking").type(NUMBER).description("순위"), + fieldWithPath("data[].postId").type(NUMBER).description("게시글 ID"), + fieldWithPath("data[].title").type(STRING).description("게시글 제목"), + fieldWithPath("data[].authorId").type(NUMBER).description("작성자 user ID"), + fieldWithPath("data[].authorName").type(STRING).description("작성자 이름"), + fieldWithPath("data[].viewCount").type(NUMBER).description("조회수"), + fieldWithPath("data[].likeCount").type(NUMBER).description("좋아요 수"), + fieldWithPath("data[].scrapCount").type(NUMBER).description("스크랩 수"), + fieldWithPath("data[].imageUrl").type(STRING).description("이미지 url"), + fieldWithPath("data[].hashtags") + .type(ARRAY) + .description("게시글 해시태그 : 한글 태그 표시. 없는 경우 빈 배열([])로 표시")); + + private ResultActions getResultActions() throws Exception { + return mockMvc.perform(RestDocumentationRequestBuilders.get("/api/posts/userpick/popular")); + } + + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "loadUserPickPopular/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + responseFields(responseFields), + resource( + ResourceSnippetParameters.builder() + .tag("유저픽 게시글") + .summary("\"요즘 인기있는 글\" 목록 조회 api") + .description("그루밍 라운지 내 \"요즘 인기있는 글\" 목록 조회 api 입니다.") + .responseFields(responseFields) + .build())); + } + + @Test + @Transactional + @DisplayName("테스트 성공") + public void test1() throws Exception { + // given + + SessionAndUser sessionAndUser = createUserAndLoginAndReturnUser(); // 로그인 처리 + + User user = sessionAndUser.user(); + + // test 용 post 생성 + savePostPort.savePost( + Post.create( + SavePostCommand.from( + user.getId(), + new SavePostRequest( + "test1", + List.of(PostHashtag.SUN_CARE, PostHashtag.CLEANSING), + "content1", + new ArrayList<>()), + new ArrayList<>(), + new ArrayList<>()))); + + savePostPort.savePost( + Post.create( + SavePostCommand.from( + user.getId(), + new SavePostRequest( + "test2", + List.of( + PostHashtag.BOTTOM_CLOTHING, + PostHashtag.FASHION_ACCESSORIES), + "content2", + new ArrayList<>()), + new ArrayList<>(), + new ArrayList<>()))); + + // when + ResultActions resultActions = getResultActions(); + + // then + resultActions + .andExpect(status().is(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data", hasSize(2))); + + // documentation + resultActions.andDo(getDocument(1)); + } +}