diff --git a/src/docs/asciidoc/post-api.adoc b/src/docs/asciidoc/post-api.adoc index 59d9438..57c4c52 100644 --- a/src/docs/asciidoc/post-api.adoc +++ b/src/docs/asciidoc/post-api.adoc @@ -323,4 +323,24 @@ include::{snippetsDir}/loadUserPickAllLatest/1/http-response.adoc[] include::{snippetsDir}/loadUserPickAllLatest/1/response-fields.adoc[] +--- + + +=== **13. 그루밍 이야기 - "인기순" 조회** + +무한 스크롤 - cursor 방식으로 구현했습니다. + +==== Request +include::{snippetsDir}/loadUserPickAllPopular/1/http-request.adoc[] + +==== Request Query Parameters +include::{snippetsDir}/loadUserPickAllPopular/1/query-parameters.adoc[] + +==== 성공 Response +include::{snippetsDir}/loadUserPickAllPopular/1/http-response.adoc[] + +==== Response Body Fields +include::{snippetsDir}/loadUserPickAllPopular/1/response-fields.adoc[] + + --- \ No newline at end of file diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/controller/GetUserPickPostsController.java b/src/main/java/com/ftm/server/adapter/in/web/post/controller/GetUserPickPostsController.java index 7797c59..012fa72 100644 --- a/src/main/java/com/ftm/server/adapter/in/web/post/controller/GetUserPickPostsController.java +++ b/src/main/java/com/ftm/server/adapter/in/web/post/controller/GetUserPickPostsController.java @@ -1,8 +1,11 @@ package com.ftm.server.adapter.in.web.post.controller; import com.ftm.server.adapter.in.web.post.dto.response.GetUserPickPostsLatestResponse; +import com.ftm.server.adapter.in.web.post.dto.response.GetUserPickPostsPopularCursorResponse; import com.ftm.server.application.port.in.post.GetUserPickPostsUseCase; import com.ftm.server.application.query.FindUserPickLatestPostsByCursorQuery; +import com.ftm.server.application.query.FindUserPickPopularPostsByCursorQuery; +import com.ftm.server.application.vo.post.GetUserPickAllPostsPopularWithCursorVo; import com.ftm.server.common.response.ApiResponse; import com.ftm.server.common.response.enums.SuccessResponseCode; import com.ftm.server.infrastructure.security.UserPrincipal; @@ -39,4 +42,22 @@ public ResponseEntity getLatestPosts( return ResponseEntity.ok(ApiResponse.success(SuccessResponseCode.OK, result)); } + + @GetMapping("/api/posts/userpick/all/popular") + public ResponseEntity getUserPickPopularPosts( + @AuthenticationPrincipal UserPrincipal user, + @RequestParam(name = "limit") Integer size, + @RequestParam(name = "lastScore", required = false) Double lastScore, + @RequestParam(name = "lastPostId", required = false) Long lastId) { + + GetUserPickAllPostsPopularWithCursorVo items = + getUserPickPostsUseCase.executePopular( + FindUserPickPopularPostsByCursorQuery.of( + size, lastScore, lastId, user == null ? null : user.getId())); + + GetUserPickPostsPopularCursorResponse response = + GetUserPickPostsPopularCursorResponse.from(items); + + return ResponseEntity.ok(ApiResponse.success(SuccessResponseCode.OK, response)); + } } diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/GetUserPickPostsPopularCursorResponse.java b/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/GetUserPickPostsPopularCursorResponse.java new file mode 100644 index 0000000..c851f3a --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/GetUserPickPostsPopularCursorResponse.java @@ -0,0 +1,23 @@ +package com.ftm.server.adapter.in.web.post.dto.response; + +import com.ftm.server.application.vo.post.GetUserPickAllPostsPopularWithCursorVo; +import com.ftm.server.application.vo.post.GetUserPickPostsVo; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GetUserPickPostsPopularCursorResponse { + + private final List data; + private final Boolean hasNext; + private final Long lastPostId; + private final Double lastScore; + + public static GetUserPickPostsPopularCursorResponse from( + GetUserPickAllPostsPopularWithCursorVo vo) { + return new GetUserPickPostsPopularCursorResponse( + vo.getPostList(), vo.getHasNext(), vo.getNextPostId(), vo.getNextScore()); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/cache/LoadUserPickAllLoadUserPickAllPopularCacheAdapter.java b/src/main/java/com/ftm/server/adapter/out/cache/LoadUserPickAllLoadUserPickAllPopularCacheAdapter.java new file mode 100644 index 0000000..f61c3c8 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/cache/LoadUserPickAllLoadUserPickAllPopularCacheAdapter.java @@ -0,0 +1,29 @@ +package com.ftm.server.adapter.out.cache; + +import static com.ftm.server.common.consts.StaticConsts.*; + +import com.ftm.server.application.port.out.cache.LoadUserPickAllPopularCachePort; +import com.ftm.server.application.port.out.persistence.post.LoadPostPort; +import com.ftm.server.application.vo.post.*; +import com.ftm.server.common.annotation.Adapter; +import java.util.*; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.Cacheable; + +@Adapter +@RequiredArgsConstructor +@CacheConfig(cacheManager = "userPickAllPopularPostsCacheManager") +public class LoadUserPickAllLoadUserPickAllPopularCacheAdapter + implements LoadUserPickAllPopularCachePort { + + private final LoadPostPort loadPostPort; + + @Override + @Cacheable( + value = USER_PICK_STORY_POPULAR_POSTS_CACHE_NAME, + key = USER_PICK_STORY_POPULAR_POSTS_CACHE_KEY_ALL) + public List getUserPickAllPopularPosts() { + return loadPostPort.loadUserPickAllPostsByPopular(); + } +} 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 7b0ef71..34c099c 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 @@ -181,6 +181,11 @@ public List loadPostIdAndBookmarkYn(FindByPostIdsAndUserQ return postRepository.findPostIdWithBookmarkYn(query); } + @Override + public List loadUserPickAllPostsByPopular() { + return postRepository.findAllPostsByPopular(); + } + @Override public List loadPostWithUserAndBookmarkCount( FindByIdsQuery query) { diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java index 2ceb80d..f0bcc57 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java @@ -131,23 +131,27 @@ public List findPostsByLatestCursor( @Override public List findPostIdWithBookmarkYn(FindByPostIdsAndUserQuery query) { - return queryFactory - .select( - Projections.constructor( - PostIdAndBookmarkYnVo.class, - postJpaEntity.id, - bookmarkJpaEntity.id.isNotNull())) - .from(postJpaEntity) - .leftJoin(bookmarkJpaEntity) - .on( - bookmarkJpaEntity - .post - .eq(postJpaEntity) - .and( - bookmarkJpaEntity.user.id.eq( - query.getUserId())) // 특정 user 북마크 여부 확인 - ) - .where(postJpaEntity.id.in(query.getPostIds())) - .fetch(); + List temp = + queryFactory + .select( + Projections.constructor( + PostIdAndBookmarkYnVo.class, + postJpaEntity.id, + bookmarkJpaEntity.id.isNotNull())) + .from(postJpaEntity) + .leftJoin(bookmarkJpaEntity) + .on( + bookmarkJpaEntity + .post + .id + .eq(postJpaEntity.id) + .and( + bookmarkJpaEntity.user.id.eq( + query.getUserId())) // 특정 user 북마크 여부 확인 + ) + .where(postJpaEntity.id.in(query.getPostIds())) + .fetch(); + + return temp; } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostWithBookmarkCustomRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostWithBookmarkCustomRepository.java index 464094c..61b7188 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostWithBookmarkCustomRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostWithBookmarkCustomRepository.java @@ -4,6 +4,7 @@ import com.ftm.server.application.query.FindPostsByCreatedDateQuery; import com.ftm.server.application.vo.post.PostWithBookmarkCountVo; import com.ftm.server.application.vo.post.PostWithUserAndBookmarkCountVo; +import com.ftm.server.application.vo.post.UserPickPopularPostCursorVo; import com.ftm.server.application.vo.post.UserWithPostCountVo; import java.util.List; @@ -17,4 +18,6 @@ List findAllPostsWithUserAndBookmarkCount( List findAllPostsWithUserAndBookmarkCount(FindByIdsQuery query); List findTopNPostsByBookmarkCount(int limit); + + List findAllPostsByPopular(); } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostWithBookmarkCustomRepositoryImpl.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostWithBookmarkCustomRepositoryImpl.java index 04e6a34..069d711 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostWithBookmarkCustomRepositoryImpl.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostWithBookmarkCustomRepositoryImpl.java @@ -5,13 +5,14 @@ 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.PostWithUserAndBookmarkCountVo; -import com.ftm.server.application.vo.post.UserWithPostCountVo; +import com.ftm.server.application.vo.post.*; import com.ftm.server.domain.enums.UserRole; +import com.querydsl.core.Tuple; import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.Duration; import java.time.LocalDateTime; +import java.util.Comparator; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -97,13 +98,14 @@ public List findAllPostsWithUserAndBookmarkCount postJpaEntity.title, postJpaEntity.content, postJpaEntity.hashtags, - postJpaEntity.viewCount, // sum() 쓰지 마세요 - postJpaEntity.likeCount, // sum() 쓰지 마세요 + postJpaEntity.viewCount, + postJpaEntity.likeCount, bookmarkJpaEntity.id.countDistinct() // 북마크 개수 )) .from(postJpaEntity) + .where(postJpaEntity.id.in(query.getIds())) .leftJoin(bookmarkJpaEntity) - .on(bookmarkJpaEntity.post.eq(postJpaEntity)) + .on(bookmarkJpaEntity.post.id.eq(postJpaEntity.id)) .groupBy( postJpaEntity.id, postJpaEntity.user.id, @@ -129,4 +131,41 @@ public List findTopNPostsByBookmarkCount(int limit) { .limit(limit) .fetch(); } + + @Override + public List findAllPostsByPopular() { + + Tuple maxValues = + queryFactory + .select(postJpaEntity.likeCount.max(), postJpaEntity.viewCount.max()) + .from(postJpaEntity) + .fetchOne(); + + int maxLike = maxValues.get(postJpaEntity.likeCount.max()); + int maxView = maxValues.get(postJpaEntity.viewCount.max()); + + List result = + queryFactory.select(postJpaEntity).from(postJpaEntity).fetch().stream() + .map( + vo -> { + double normLike = + maxLike > 0 ? vo.getLikeCount() / (double) maxLike : 0; + double normView = + maxView > 0 ? vo.getViewCount() / (double) maxView : 0; + double weighted = normLike * 0.6 + normView * 0.4; + + long hours = + Duration.between(vo.getCreatedAt(), LocalDateTime.now()) + .toHours(); + double timeDecay = Math.exp(-0.1 * hours); + + return new UserPickPopularPostCursorVo( + vo.getId(), weighted * timeDecay); + }) + .sorted( + Comparator.comparing(UserPickPopularPostCursorVo::getScore) + .reversed()) + .toList(); + return result; + } } diff --git a/src/main/java/com/ftm/server/application/port/in/post/GetUserPickPostsUseCase.java b/src/main/java/com/ftm/server/application/port/in/post/GetUserPickPostsUseCase.java index 474cfa0..4ed580e 100644 --- a/src/main/java/com/ftm/server/application/port/in/post/GetUserPickPostsUseCase.java +++ b/src/main/java/com/ftm/server/application/port/in/post/GetUserPickPostsUseCase.java @@ -1,11 +1,16 @@ package com.ftm.server.application.port.in.post; import com.ftm.server.application.query.FindUserPickLatestPostsByCursorQuery; +import com.ftm.server.application.query.FindUserPickPopularPostsByCursorQuery; import com.ftm.server.application.vo.post.GetUserPickAllPostsLatestWithCursorVo; +import com.ftm.server.application.vo.post.GetUserPickAllPostsPopularWithCursorVo; import com.ftm.server.common.annotation.UseCase; @UseCase public interface GetUserPickPostsUseCase { GetUserPickAllPostsLatestWithCursorVo executeLatest(FindUserPickLatestPostsByCursorQuery query); + + GetUserPickAllPostsPopularWithCursorVo executePopular( + FindUserPickPopularPostsByCursorQuery query); } diff --git a/src/main/java/com/ftm/server/application/port/out/cache/LoadUserPickAllPopularCachePort.java b/src/main/java/com/ftm/server/application/port/out/cache/LoadUserPickAllPopularCachePort.java new file mode 100644 index 0000000..f483965 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/cache/LoadUserPickAllPopularCachePort.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.port.out.cache; + +import com.ftm.server.application.vo.post.UserPickPopularPostCursorVo; +import com.ftm.server.common.annotation.Port; +import java.util.List; + +@Port +public interface LoadUserPickAllPopularCachePort { + + List getUserPickAllPopularPosts(); +} 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 905d00e..4745447 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.*; import com.ftm.server.application.vo.post.BookmarkYnWrapperVo; import com.ftm.server.application.vo.post.PostIdAndBookmarkYnVo; +import com.ftm.server.application.vo.post.UserPickPopularPostCursorVo; import com.ftm.server.common.annotation.Port; import com.ftm.server.domain.entity.Post; import java.util.List; @@ -27,4 +28,6 @@ List loadUserPickAllPostsByLatest( FindUserPickLatestPostsByCursorQuery query); List loadPostIdAndBookmarkYn(FindByPostIdsAndUserQuery query); + + List loadUserPickAllPostsByPopular(); } diff --git a/src/main/java/com/ftm/server/application/query/FindUserPickPopularPostsByCursorQuery.java b/src/main/java/com/ftm/server/application/query/FindUserPickPopularPostsByCursorQuery.java new file mode 100644 index 0000000..79708e2 --- /dev/null +++ b/src/main/java/com/ftm/server/application/query/FindUserPickPopularPostsByCursorQuery.java @@ -0,0 +1,19 @@ +package com.ftm.server.application.query; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class FindUserPickPopularPostsByCursorQuery { + + private Integer size; + private Double lastScore; // nullable + private Long lastId; // nullable + private final Long userId; + + public static FindUserPickPopularPostsByCursorQuery of( + Integer size, Double lastScore, Long lastId, Long userId) { + return new FindUserPickPopularPostsByCursorQuery(size, lastScore, lastId, userId); + } +} diff --git a/src/main/java/com/ftm/server/application/service/post/GetUserPickAllPostsByLatestService.java b/src/main/java/com/ftm/server/application/service/post/GetUserPickAllPostsByLatestService.java deleted file mode 100644 index 6d2a6b8..0000000 --- a/src/main/java/com/ftm/server/application/service/post/GetUserPickAllPostsByLatestService.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.ftm.server.application.service.post; - -import static java.util.stream.Collectors.toMap; - -import com.ftm.server.application.port.in.post.GetUserPickPostsUseCase; -import com.ftm.server.application.port.out.persistence.post.LoadPostImagePort; -import com.ftm.server.application.port.out.persistence.post.LoadPostPort; -import com.ftm.server.application.port.out.persistence.post.LoadPostWithBookmarkCountPort; -import com.ftm.server.application.query.FindByIdsQuery; -import com.ftm.server.application.query.FindUserPickLatestPostsByCursorQuery; -import com.ftm.server.application.vo.post.*; -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.time.LocalDateTime; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class GetUserPickAllPostsByLatestService implements GetUserPickPostsUseCase { - - private final LoadPostPort loadPostPort; - private final LoadPostWithBookmarkCountPort loadPostWithBookmarkCountPort; - private final LoadPostImagePort loadPostImagePort; - - @Override - public GetUserPickAllPostsLatestWithCursorVo executeLatest( - FindUserPickLatestPostsByCursorQuery query) { - - List postsWithBookmarkYn = - loadPostPort.loadUserPickAllPostsByLatest(query); // 최신순으로 조회. 마지막 cursor 위치를 기준으로 - - if (postsWithBookmarkYn.isEmpty()) { - return GetUserPickAllPostsLatestWithCursorVo.of(List.of(), false, null); - } - - boolean hasNext = false; - LocalDateTime lastCreatedAt = null; - if (postsWithBookmarkYn.size() > query.getLimit()) { - hasNext = true; - postsWithBookmarkYn = postsWithBookmarkYn.subList(0, query.getLimit()); - lastCreatedAt = - ((Post) postsWithBookmarkYn.get(query.getLimit() - 1).getData()).getCreatedAt(); - } - - List result = convertToVo(postsWithBookmarkYn.stream().toList()); - - return GetUserPickAllPostsLatestWithCursorVo.of(result, hasNext, lastCreatedAt); - } - - private List convertToVo(List posts) { - - List postIds = posts.stream().map(b -> ((Post) b.getData()).getId()).toList(); - - Map detailPostMap = - loadPostWithBookmarkCountPort - .loadPostWithUserAndBookmarkCount(FindByIdsQuery.from(postIds)) - .stream() - .collect(toMap(PostWithUserAndBookmarkCountVo::getId, vo -> vo)); - - Map imageUrlMap = - loadPostImagePort - .loadRepresentativeImagesByPostIds(FindByIdsQuery.from(postIds)) - .stream() - .collect(toMap(PostImage::getPostId, PostImage::getObjectKey, (a, b) -> a)); - - return posts.stream() - .map( - b -> { - PostWithUserAndBookmarkCountVo p = - detailPostMap.get(((Post) b.getData()).getId()); - String imageUrl = - imageUrlMap.getOrDefault( - p.getId(), PropertiesHolder.POST_DEFAULT_IMAGE); - List hashtags = toHashtagList(p.getHashtags()); - - return GetUserPickPostsVo.of( - p, - PropertiesHolder.CDN_PATH + "/" + imageUrl, - hashtags, - b.getBookmarkYn()); - }) - .toList(); - } - - private List toHashtagList(PostHashtag[] hashtags) { - return (hashtags == null || hashtags.length == 0) - ? List.of() - : Arrays.stream(hashtags).map(PostHashtag::getTag).toList(); - } -} diff --git a/src/main/java/com/ftm/server/application/service/post/GetUserPickAllPostsService.java b/src/main/java/com/ftm/server/application/service/post/GetUserPickAllPostsService.java new file mode 100644 index 0000000..6a592ef --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/GetUserPickAllPostsService.java @@ -0,0 +1,226 @@ +package com.ftm.server.application.service.post; + +import static java.util.stream.Collectors.toMap; + +import com.ftm.server.application.port.in.post.GetUserPickPostsUseCase; +import com.ftm.server.application.port.out.cache.LoadUserPickAllPopularCachePort; +import com.ftm.server.application.port.out.persistence.post.LoadPostImagePort; +import com.ftm.server.application.port.out.persistence.post.LoadPostPort; +import com.ftm.server.application.port.out.persistence.post.LoadPostWithBookmarkCountPort; +import com.ftm.server.application.query.FindByIdsQuery; +import com.ftm.server.application.query.FindByPostIdsAndUserQuery; +import com.ftm.server.application.query.FindUserPickLatestPostsByCursorQuery; +import com.ftm.server.application.query.FindUserPickPopularPostsByCursorQuery; +import com.ftm.server.application.vo.post.*; +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.time.LocalDateTime; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GetUserPickAllPostsService implements GetUserPickPostsUseCase { + + private final LoadPostPort loadPostPort; + private final LoadPostWithBookmarkCountPort loadPostWithBookmarkCountPort; + private final LoadPostImagePort loadPostImagePort; + private final LoadUserPickAllPopularCachePort loadUserPickAllPopularCachePort; + + @Override + public GetUserPickAllPostsLatestWithCursorVo executeLatest( + FindUserPickLatestPostsByCursorQuery query) { + + List postsWithBookmarkYn = + loadPostPort.loadUserPickAllPostsByLatest(query); // 최신순으로 조회. 마지막 cursor 위치를 기준으로 + + if (postsWithBookmarkYn.isEmpty()) { + return GetUserPickAllPostsLatestWithCursorVo.of(List.of(), false, null); + } + + boolean hasNext = false; + LocalDateTime lastCreatedAt = null; + if (postsWithBookmarkYn.size() > query.getLimit()) { + hasNext = true; + postsWithBookmarkYn = postsWithBookmarkYn.subList(0, query.getLimit()); + lastCreatedAt = + ((Post) postsWithBookmarkYn.get(query.getLimit() - 1).getData()).getCreatedAt(); + } + + List result = convertToVo(postsWithBookmarkYn.stream().toList()); + + return GetUserPickAllPostsLatestWithCursorVo.of(result, hasNext, lastCreatedAt); + } + + @Override + public GetUserPickAllPostsPopularWithCursorVo executePopular( + FindUserPickPopularPostsByCursorQuery query) { + List allPosts = + loadUserPickAllPopularCachePort.getUserPickAllPopularPosts(); + + // Step 1. 커서 조건 적용 (lastScore, lastId 기준) + List filteredPosts = applyCursorFilter(allPosts, query); + + // Step 2. 인기 점수 기준 내림차순 정렬 + 페이징(limit + 1) + List sortedAndPaged = + filteredPosts.stream() + .sorted( + Comparator.comparingDouble(UserPickPopularPostCursorVo::getScore) + .reversed()) + .limit(query.getSize() + 1) + .toList(); + + // Step 3. 다음 페이지 여부 판단 + boolean hasNext = sortedAndPaged.size() > query.getSize(); + if (hasNext) { + sortedAndPaged = sortedAndPaged.subList(0, query.getSize()); + } + + // Step 4. 다음 커서 계산 + Double nextScore = hasNext ? getLast(sortedAndPaged).getScore() : null; + Long nextId = hasNext ? getLast(sortedAndPaged).getPostId() : null; + + // Step 5. 포스트 상세 조회 + 북마크 여부 매핑 + List postIds = + sortedAndPaged.stream().map(UserPickPopularPostCursorVo::getPostId).toList(); + + List bookmarkInfoList = + resolveBookmarkInfo(postIds, query.getUserId()); + + // Step 6. 응답 변환 + List posts = convertToVo2(bookmarkInfoList); + + return GetUserPickAllPostsPopularWithCursorVo.of(posts, hasNext, nextId, nextScore); + } + + /** 커서 기반 필터링 수행 */ + private List applyCursorFilter( + List posts, FindUserPickPopularPostsByCursorQuery query) { + + Long lastId = query.getLastId(); + Double lastScore = query.getLastScore(); + + if (lastId == null || lastScore == null) { + return posts; + } + + return posts.stream() + .filter( + p -> + p.getScore() < lastScore + || (p.getScore().equals(lastScore) + && p.getPostId() < lastId)) + .toList(); + } + + /** userId 존재 여부에 따라 북마크 정보 로드 */ + private List resolveBookmarkInfo(List postIds, Long userId) { + if (userId == null) { + return postIds.stream().map(id -> new PostIdAndBookmarkYnVo(id, false)).toList(); + } + // DB에서 가져온 결과 (순서 보장 안됨) + List unorderedList = + loadPostPort.loadPostIdAndBookmarkYn(FindByPostIdsAndUserQuery.of(postIds, userId)); + + // postId -> VO 맵핑 + Map map = + unorderedList.stream() + .collect(Collectors.toMap(PostIdAndBookmarkYnVo::getPostId, vo -> vo)); + + // 원래 postIds 순서대로 정렬 + return postIds.stream().map(map::get).toList(); + } + + /** 북마크 및 포스트 상세 데이터를 통합하여 응답 VO로 변환 */ + private List convertToVo2(List posts) { + List postIds = posts.stream().map(PostIdAndBookmarkYnVo::getPostId).toList(); + + // Post 상세 정보 + Map postDetailMap = + loadPostWithBookmarkCountPort + .loadPostWithUserAndBookmarkCount(FindByIdsQuery.from(postIds)) + .stream() + .collect(Collectors.toMap(PostWithUserAndBookmarkCountVo::getId, vo -> vo)); + + // 대표 이미지 + Map imageMap = + loadPostImagePort + .loadRepresentativeImagesByPostIds(FindByIdsQuery.from(postIds)) + .stream() + .collect( + Collectors.toMap( + PostImage::getPostId, + PostImage::getObjectKey, + (a, b) -> a)); + + return posts.stream() + .map( + b -> { + PostWithUserAndBookmarkCountVo post = postDetailMap.get(b.getPostId()); + String imageUrl = + imageMap.getOrDefault( + post.getId(), PropertiesHolder.POST_DEFAULT_IMAGE); + List hashtags = toHashtagList(post.getHashtags()); + + return GetUserPickPostsVo.of( + post, + PropertiesHolder.CDN_PATH + "/" + imageUrl, + hashtags, + b.getBookmarkYn()); + }) + .toList(); + } + + /** 리스트의 마지막 요소를 안전하게 반환 */ + private T getLast(List list) { + return list.get(list.size() - 1); + } + + private List convertToVo(List posts) { + + List postIds = posts.stream().map(b -> ((Post) b.getData()).getId()).toList(); + + Map detailPostMap = + loadPostWithBookmarkCountPort + .loadPostWithUserAndBookmarkCount(FindByIdsQuery.from(postIds)) + .stream() + .collect(toMap(PostWithUserAndBookmarkCountVo::getId, vo -> vo)); + + Map imageUrlMap = + loadPostImagePort + .loadRepresentativeImagesByPostIds(FindByIdsQuery.from(postIds)) + .stream() + .collect(toMap(PostImage::getPostId, PostImage::getObjectKey, (a, b) -> a)); + + return posts.stream() + .map( + b -> { + PostWithUserAndBookmarkCountVo p = + detailPostMap.get(((Post) b.getData()).getId()); + String imageUrl = + imageUrlMap.getOrDefault( + p.getId(), PropertiesHolder.POST_DEFAULT_IMAGE); + List hashtags = toHashtagList(p.getHashtags()); + + return GetUserPickPostsVo.of( + p, + PropertiesHolder.CDN_PATH + "/" + imageUrl, + hashtags, + b.getBookmarkYn()); + }) + .toList(); + } + + private List toHashtagList(PostHashtag[] hashtags) { + return (hashtags == null || hashtags.length == 0) + ? List.of() + : Arrays.stream(hashtags).map(PostHashtag::getTag).toList(); + } +} diff --git a/src/main/java/com/ftm/server/application/vo/post/GetUserPickAllPostsPopularWithCursorVo.java b/src/main/java/com/ftm/server/application/vo/post/GetUserPickAllPostsPopularWithCursorVo.java new file mode 100644 index 0000000..43bbd6d --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/GetUserPickAllPostsPopularWithCursorVo.java @@ -0,0 +1,23 @@ +package com.ftm.server.application.vo.post; + +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class GetUserPickAllPostsPopularWithCursorVo { + + List postList; + Boolean hasNext; + private Long nextPostId; + private Double nextScore; + + public static GetUserPickAllPostsPopularWithCursorVo of( + List postList, Boolean hasNext, Long nextPostId, Double nextScore) { + return new GetUserPickAllPostsPopularWithCursorVo(postList, hasNext, nextPostId, nextScore); + } +} diff --git a/src/main/java/com/ftm/server/application/vo/post/PostIdAndBookmarkYnVo.java b/src/main/java/com/ftm/server/application/vo/post/PostIdAndBookmarkYnVo.java index 81a5433..5c79724 100644 --- a/src/main/java/com/ftm/server/application/vo/post/PostIdAndBookmarkYnVo.java +++ b/src/main/java/com/ftm/server/application/vo/post/PostIdAndBookmarkYnVo.java @@ -8,4 +8,8 @@ public class PostIdAndBookmarkYnVo { private final Long postId; private final Boolean bookmarkYn; + + public static PostIdAndBookmarkYnVo of(Long postId, Boolean bookmarkYn) { + return new PostIdAndBookmarkYnVo(postId, bookmarkYn); + } } diff --git a/src/main/java/com/ftm/server/application/vo/post/UserPickPopularPostCursorVo.java b/src/main/java/com/ftm/server/application/vo/post/UserPickPopularPostCursorVo.java new file mode 100644 index 0000000..c75334c --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/UserPickPopularPostCursorVo.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.vo.post; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserPickPopularPostCursorVo { + private Long postId; + private Double score; +} 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 ea83349..27d4a54 100644 --- a/src/main/java/com/ftm/server/common/consts/StaticConsts.java +++ b/src/main/java/com/ftm/server/common/consts/StaticConsts.java @@ -28,4 +28,8 @@ public class StaticConsts { public static final String USER_PICK_TOP_BOOKMARK_POSTS_CACHE_NAME = "ftm:posts:userpick:top-bookmarks"; public static final String USER_PICK_TOP_BOOKMARK_POSTS_CACHE_KEY_ALL = "'all'"; + + public static final String USER_PICK_STORY_POPULAR_POSTS_CACHE_NAME = + "ftm:posts:userpick:top-bookmarks"; + public static final String USER_PICK_STORY_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 fcaf6e0..a5376c6 100644 --- a/src/main/java/com/ftm/server/infrastructure/cache/CaffeineCacheConfig.java +++ b/src/main/java/com/ftm/server/infrastructure/cache/CaffeineCacheConfig.java @@ -63,4 +63,12 @@ public CaffeineCacheManager cacheManagerForUserPickTopBookmarkPosts() { mgr.setCaffeine(Caffeine.newBuilder().maximumSize(10).expireAfterWrite(1, TimeUnit.HOURS)); return mgr; } + + @Bean("userPickAllPopularPostsCacheManager") // 유저픽 게시글 - 그루밍 이야기 - 인기순 api + public CaffeineCacheManager cacheManagerForUserPickAllPopularPosts() { + CaffeineCacheManager mgr = new CaffeineCacheManager(); + mgr.setCaffeine( + Caffeine.newBuilder().maximumSize(10).expireAfterWrite(2, TimeUnit.MINUTES)); + return mgr; + } } diff --git a/src/test/java/com/ftm/server/post/LoadUserPickAllPopularPostsTest.java b/src/test/java/com/ftm/server/post/LoadUserPickAllPopularPostsTest.java new file mode 100644 index 0000000..113475b --- /dev/null +++ b/src/test/java/com/ftm/server/post/LoadUserPickAllPopularPostsTest.java @@ -0,0 +1,174 @@ +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.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +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.restdocs.request.ParameterDescriptor; +import org.springframework.restdocs.snippet.Attributes; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +public class LoadUserPickAllPopularPostsTest extends BaseTest { + + @Autowired private SavePostPort savePostPort; + + private final List queryParameters = + List.of( + parameterWithName("limit") + .description("한번 로딩 시 불러올 게시글의 개수") + .attributes(new Attributes.Attribute("constraint", "Integer")), + parameterWithName("lastScore") + .optional() + .description("이전 로딩의 lastScore 입력") + .attributes( + new Attributes.Attribute( + "constraint", + "이전 스크롤 로딩 응답값(lastScore) 필드 그대로 사용. 첫번째 스크롤인 경우 전달 X")), + parameterWithName("lastPostId") + .optional() + .description("이전 로딩의 lastId 입력") + .attributes( + new Attributes.Attribute( + "constraint", + "이전 스크롤 로딩 응답값(lastPostId) 필드 그대로 사용. 첫번째 스크롤인 경우 전달 X"))); + + private final List responseFields = + List.of( + fieldWithPath("status").type(NUMBER).description("응답 상태"), + fieldWithPath("code").type(STRING).description("상태 코드"), + fieldWithPath("message").type(STRING).description("메시지"), + fieldWithPath("data").type(OBJECT).optional().description("응답 데이터"), + fieldWithPath("data.data") + .type(ARRAY) + .optional() + .description("응답 데이터가 없으면 빈 배열"), + fieldWithPath("data.data[].postId").type(NUMBER).description("게시글 ID"), + fieldWithPath("data.data[].title").type(STRING).description("게시글 제목"), + fieldWithPath("data.data[].authorId").type(NUMBER).description("작성자 user ID"), + fieldWithPath("data.data[].authorName").type(STRING).description("작성자 이름"), + fieldWithPath("data.data[].viewCount").type(NUMBER).description("조회수"), + fieldWithPath("data.data[].likeCount").type(NUMBER).description("좋아요 수"), + fieldWithPath("data.data[].scrapCount").type(NUMBER).description("스크랩 수"), + fieldWithPath("data.data[].imageUrl").type(STRING).description("이미지 url"), + fieldWithPath("data.data[].hashtags") + .type(ARRAY) + .description("게시글 해시태그 : 한글 태그 표시. 없는 경우 빈 배열([])로 표시"), + fieldWithPath("data.data[].userBookmarkYn") + .type(BOOLEAN) + .description("사용자 북마크 등록 여부"), + fieldWithPath("data.lastScore") + .type(STRING) + .optional() + .description("다음 스크롤 로딩 요청 시, 요청 파라미터 lastScore의 값이 됨") + .attributes( + new Attributes.Attribute( + "nullable", "hasNext가 false인 경우 null")), + fieldWithPath("data.lastPostId") + .type(NUMBER) + .optional() + .description("다음 스크롤 로딩 요청 시, 요청 파라미터 lastPostId의 값이 됨") + .attributes( + new Attributes.Attribute( + "nullable", "hasNext가 false인 경우 null")), + fieldWithPath("data.hasNext") + .type(BOOLEAN) + .description("불러올 게시글이 추가로 남았는지 여부")); + + private ResultActions getResultActions() throws Exception { + return mockMvc.perform( + RestDocumentationRequestBuilders.get("/api/posts/userpick/all/popular") + .queryParam("limit", "5")); + } + + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "loadUserPickAllPopular/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + responseFields(responseFields), + queryParameters(queryParameters), + resource( + ResourceSnippetParameters.builder() + .tag("유저픽 게시글") + .summary("\"그루밍 이야기\" 인기순 조회 api") + .description("그루밍 라운지 내 \"그루밍 이야기\" 인기순 목록 조회 api 입니다.") + .responseFields(responseFields) + .build())); + } + + @Test + @Transactional + @DisplayName("테스트 성공") + public void test1() throws Exception { + // given + + BaseTest.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.data", hasSize(2))); + + // documentation + resultActions.andDo(getDocument(1)); + } +}