From 266adc7a70fd904e96559c62d91b43f975c55717 Mon Sep 17 00:00:00 2001 From: myqewr Date: Wed, 19 Nov 2025 20:01:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feature=20:=20=ED=95=B4=EC=8B=9C=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=B3=84=20=EC=83=81=ED=92=88=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20api(#164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/post-api.adoc | 20 ++ .../LoadProductsByHashTagController.java | 44 +++++ .../request/LoadProductsByHashTagRequest.java | 12 ++ .../LoadProductsByHashTagResponse.java | 20 ++ .../LoadProductsByPopularityCacheAdapter.java | 41 ++++ .../post/PostDomainPersistenceAdapter.java | 32 ++++ .../PostProductCustomRepository.java | 8 + .../PostProductCustomRepositoryImpl.java | 56 ++++++ .../repository/PostProductRepository.java | 19 +- .../repository/ProductLikeRepository.java | 20 +- .../LoadProductsByPopularityScheduler.java | 21 +++ .../in/post/LoadProductsByHashTagUseCase.java | 13 ++ .../LoadProductsByPopularityCachePort.java | 11 ++ .../persistence/post/LoadPostProductPort.java | 8 + .../persistence/post/LoadProductLikePort.java | 3 + .../query/FindByProductHashTagsQuery.java | 12 ++ .../post/LoadProductsByHashTagService.java | 129 +++++++++++++ .../vo/post/LoadProductAndUserLikeVo.java | 12 ++ .../vo/post/LoadProductsByHashTagVo.java | 35 ++++ .../post/LoadProductsByHashTagVoWrapper.java | 16 ++ .../vo/post/ProductIdAndScoreVo.java | 9 + .../server/common/consts/StaticConsts.java | 3 + .../cache/CaffeineCacheConfig.java | 8 + .../security/SecurityConfig.java | 3 +- .../post/LoadProductsByHashTagTest.java | 178 ++++++++++++++++++ src/test/resources/application-test.yml | 4 +- 26 files changed, 730 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/controller/LoadProductsByHashTagController.java create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/dto/request/LoadProductsByHashTagRequest.java create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/dto/response/LoadProductsByHashTagResponse.java create mode 100644 src/main/java/com/ftm/server/adapter/out/cache/LoadProductsByPopularityCacheAdapter.java create mode 100644 src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductCustomRepository.java create mode 100644 src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductCustomRepositoryImpl.java create mode 100644 src/main/java/com/ftm/server/adapter/out/scheduler/LoadProductsByPopularityScheduler.java create mode 100644 src/main/java/com/ftm/server/application/port/in/post/LoadProductsByHashTagUseCase.java create mode 100644 src/main/java/com/ftm/server/application/port/out/cache/LoadProductsByPopularityCachePort.java create mode 100644 src/main/java/com/ftm/server/application/query/FindByProductHashTagsQuery.java create mode 100644 src/main/java/com/ftm/server/application/service/post/LoadProductsByHashTagService.java create mode 100644 src/main/java/com/ftm/server/application/vo/post/LoadProductAndUserLikeVo.java create mode 100644 src/main/java/com/ftm/server/application/vo/post/LoadProductsByHashTagVo.java create mode 100644 src/main/java/com/ftm/server/application/vo/post/LoadProductsByHashTagVoWrapper.java create mode 100644 src/main/java/com/ftm/server/application/vo/post/ProductIdAndScoreVo.java create mode 100644 src/test/java/com/ftm/server/post/LoadProductsByHashTagTest.java diff --git a/src/docs/asciidoc/post-api.adoc b/src/docs/asciidoc/post-api.adoc index 502fa6d..3aefaee 100644 --- a/src/docs/asciidoc/post-api.adoc +++ b/src/docs/asciidoc/post-api.adoc @@ -369,3 +369,23 @@ include::{snippetsDir}/createProductLike/2/http-response.adoc[] --- + + +=== **15. 해시태그 추천 - "해시태그 기반 상품 조회** + +게시글 조회 api 이지만, Request Body가 필요하여 "POST" 메서드로 구현하였습니다. + +==== Request +include::{snippetsDir}/loadPostProducts/1/http-request.adoc[] + +==== Request Body Parameters +include::{snippetsDir}/loadPostProducts/1/request-fields.adoc[] + +==== 성공 Response +include::{snippetsDir}/loadPostProducts/1/http-response.adoc[] + +==== Response Body Fields +include::{snippetsDir}/loadPostProducts/1/response-fields.adoc[] + + +--- \ No newline at end of file diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/controller/LoadProductsByHashTagController.java b/src/main/java/com/ftm/server/adapter/in/web/post/controller/LoadProductsByHashTagController.java new file mode 100644 index 0000000..0ef6443 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/controller/LoadProductsByHashTagController.java @@ -0,0 +1,44 @@ +package com.ftm.server.adapter.in.web.post.controller; + +import com.ftm.server.adapter.in.web.post.dto.request.LoadProductsByHashTagRequest; +import com.ftm.server.adapter.in.web.post.dto.response.LoadProductsByHashTagResponse; +import com.ftm.server.application.port.in.post.LoadProductsByHashTagUseCase; +import com.ftm.server.application.vo.post.LoadProductsByHashTagVoWrapper; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.common.response.enums.SuccessResponseCode; +import com.ftm.server.infrastructure.security.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class LoadProductsByHashTagController { + + private final LoadProductsByHashTagUseCase useCase; + + @PostMapping("/api/posts/products") + public ResponseEntity loadProductsByHashTag( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam(value = "size") int size, + @RequestParam(value = "lastScore", required = false) Double lastScore, + @RequestBody(required = false) LoadProductsByHashTagRequest request) { + + LoadProductsByHashTagVoWrapper result = + useCase.execute( + userPrincipal == null ? null : userPrincipal.getId(), + request == null ? null : request.getHashTagList(), + size, + lastScore); + + return ResponseEntity.ok() + .body( + ApiResponse.success( + SuccessResponseCode.OK, + LoadProductsByHashTagResponse.from(result))); + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/LoadProductsByHashTagRequest.java b/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/LoadProductsByHashTagRequest.java new file mode 100644 index 0000000..ca66c80 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/LoadProductsByHashTagRequest.java @@ -0,0 +1,12 @@ +package com.ftm.server.adapter.in.web.post.dto.request; + +import com.ftm.server.domain.enums.ProductHashtag; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class LoadProductsByHashTagRequest { + private final List hashTagList; +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/LoadProductsByHashTagResponse.java b/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/LoadProductsByHashTagResponse.java new file mode 100644 index 0000000..7c5145b --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/dto/response/LoadProductsByHashTagResponse.java @@ -0,0 +1,20 @@ +package com.ftm.server.adapter.in.web.post.dto.response; + +import com.ftm.server.application.vo.post.LoadProductsByHashTagVo; +import com.ftm.server.application.vo.post.LoadProductsByHashTagVoWrapper; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class LoadProductsByHashTagResponse { + private final List data; + private final Boolean hasNext; + private final Double lastScore; + + public static LoadProductsByHashTagResponse from(LoadProductsByHashTagVoWrapper wrapper) { + return new LoadProductsByHashTagResponse( + wrapper.getData(), wrapper.getHasNext(), wrapper.getLastScore()); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/cache/LoadProductsByPopularityCacheAdapter.java b/src/main/java/com/ftm/server/adapter/out/cache/LoadProductsByPopularityCacheAdapter.java new file mode 100644 index 0000000..1c69ce9 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/cache/LoadProductsByPopularityCacheAdapter.java @@ -0,0 +1,41 @@ +package com.ftm.server.adapter.out.cache; + +import static com.ftm.server.common.consts.StaticConsts.*; + +import com.ftm.server.application.port.out.cache.LoadProductsByPopularityCachePort; +import com.ftm.server.application.port.out.persistence.post.LoadPostProductPort; +import com.ftm.server.application.vo.post.ProductIdAndScoreVo; +import com.ftm.server.common.annotation.Adapter; +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; + +@Adapter +@Slf4j +@CacheConfig(cacheManager = "trendingUsersCacheManager") +@RequiredArgsConstructor +public class LoadProductsByPopularityCacheAdapter implements LoadProductsByPopularityCachePort { + + private final LoadPostProductPort loadPostProductPort; + + @Override + @Cacheable( + value = PRODUCTS_BY_POPULARITY_CACHE_NAME, + key = PRODUCTS_BY_POPULARITY_CACHE_KEY_ALL) + public List loadProductsByPopularity() { + return execute(); + } + + @Override + @CachePut(value = PRODUCTS_BY_POPULARITY_CACHE_NAME, key = PRODUCTS_BY_POPULARITY_CACHE_KEY_ALL) + public List updateProductsByPopularity() { + return execute(); + } + + public List execute() { + return loadPostProductPort.loadAllProductsByPopularity(); + } +} 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 7ee48e5..bac1d6f 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 @@ -10,6 +10,7 @@ import com.ftm.server.common.exception.CustomException; import com.ftm.server.common.response.enums.ErrorResponseCode; import com.ftm.server.domain.entity.*; +import com.ftm.server.domain.enums.ProductHashtag; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -241,6 +242,31 @@ public List loadPostProductsByPostIds(FindByIdsQuery query) { .toList(); } + @Override + public List loadPostProductsByHashTags(FindByProductHashTagsQuery query) { + return postProductRepository + .findByHashtags( + query.getProductHashtagList().stream() + .map(ProductHashtag::name) + .toList() + .toArray(new String[0])) + .stream() + .map(p -> postProductMapper.toDomainEntity(p)) + .toList(); + } + + @Override + public List loadAllPostProduct() { + return postProductRepository.findAllByLatest().stream() + .map(p -> postProductMapper.toDomainEntity(p)) + .toList(); + } + + @Override + public List loadAllProductsByPopularity() { + return postProductRepository.findAllByPopularity(); + } + @Override public List loadPostProductImagesByPostProductIds(FindByIdsQuery query) { List postProductImageJpaEntities = @@ -405,4 +431,10 @@ public void saveProductLike(ProductLike productLike) { public void deleteProductLike(Long productLikeId) { productLikeRepository.deleteById(productLikeId); } + + @Override + public List findProductLikeByUser( + Long userId, List productIds) { + return productLikeRepository.findProductLikeByUser(userId, productIds); + } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductCustomRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductCustomRepository.java new file mode 100644 index 0000000..083abe9 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductCustomRepository.java @@ -0,0 +1,8 @@ +package com.ftm.server.adapter.out.persistence.repository; + +import com.ftm.server.application.vo.post.ProductIdAndScoreVo; +import java.util.List; + +public interface PostProductCustomRepository { + List findAllByPopularity(); +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductCustomRepositoryImpl.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductCustomRepositoryImpl.java new file mode 100644 index 0000000..4414d5e --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductCustomRepositoryImpl.java @@ -0,0 +1,56 @@ +package com.ftm.server.adapter.out.persistence.repository; + +import static com.ftm.server.adapter.out.persistence.model.QPostJpaEntity.postJpaEntity; +import static com.ftm.server.adapter.out.persistence.model.QPostProductJpaEntity.postProductJpaEntity; + +import com.ftm.server.application.vo.post.ProductIdAndScoreVo; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PostProductCustomRepositoryImpl implements PostProductCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByPopularity() { + + Tuple maxValues = + queryFactory + .select( + postJpaEntity.viewCount.max(), + postProductJpaEntity.recommendedCount.max()) + .from(postProductJpaEntity) + .join(postProductJpaEntity.post, postJpaEntity) + .fetchOne(); + + Integer maxView = maxValues.get(postJpaEntity.viewCount.max()); + maxView = maxView == null || maxView == 0 ? 1 : maxView; + Integer maxLike = maxValues.get(postProductJpaEntity.recommendedCount.max()).intValue(); + maxLike = maxLike == null || maxLike == 0 ? 1 : maxLike; + + NumberExpression normalizedScore = + postJpaEntity + .viewCount + .divide(maxView) + .doubleValue() + .add(postProductJpaEntity.recommendedCount.divide(maxLike).doubleValue()); + + return queryFactory + .select( + Projections.constructor( + ProductIdAndScoreVo.class, + postProductJpaEntity.id, + normalizedScore)) + .from(postProductJpaEntity) + .join(postProductJpaEntity.post, postJpaEntity) + .orderBy(normalizedScore.desc()) + .fetch(); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java index 786f326..b03e93e 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java @@ -7,10 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; -@Repository -public interface PostProductRepository extends JpaRepository { +public interface PostProductRepository + extends JpaRepository, PostProductCustomRepository { List findAllByPost(PostJpaEntity post); @@ -19,4 +18,18 @@ public interface PostProductRepository extends JpaRepository postProductIds); + + @Query("SELECT p FROM PostProductJpaEntity p order by p.createdAt desc ") + List findAllByLatest(); + + @Query( + value = + """ + SELECT * + FROM post_product + WHERE hashtags && CAST(:hashtags AS product_hashtag[]) + ORDER BY created_at DESC + """, + nativeQuery = true) + List findByHashtags(@Param("hashtags") String[] hashtags); } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/ProductLikeRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/ProductLikeRepository.java index 2566522..8af358d 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/ProductLikeRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/ProductLikeRepository.java @@ -1,15 +1,33 @@ package com.ftm.server.adapter.out.persistence.repository; import com.ftm.server.adapter.out.persistence.model.ProductLikeJpaEntity; +import com.ftm.server.application.vo.post.LoadProductAndUserLikeVo; +import io.lettuce.core.dynamic.annotation.Param; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface ProductLikeRepository extends JpaRepository { + @Query( + """ + SELECT new com.ftm.server.application.vo.post.LoadProductAndUserLikeVo( + p.id, + :userId, + CASE WHEN pl.id IS NOT NULL THEN true ELSE false END + ) + FROM PostProductJpaEntity p + LEFT JOIN ProductLikeJpaEntity pl + ON pl.postProduct.id = p.id + AND pl.user.id = :userId + WHERE p.id in (:postProductIds) + """) + List findProductLikeByUser( + @Param("userId") Long userId, @Param("postProductIds") List postProductIds); + @Query( "select p.id from ProductLikeJpaEntity p where p.user.id =:userId and p.postProduct.id =:productId") Optional findByUserAndAndPostProduct( diff --git a/src/main/java/com/ftm/server/adapter/out/scheduler/LoadProductsByPopularityScheduler.java b/src/main/java/com/ftm/server/adapter/out/scheduler/LoadProductsByPopularityScheduler.java new file mode 100644 index 0000000..ac74c3f --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/scheduler/LoadProductsByPopularityScheduler.java @@ -0,0 +1,21 @@ +package com.ftm.server.adapter.out.scheduler; + +import com.ftm.server.application.port.out.cache.LoadProductsByPopularityCachePort; +import com.ftm.server.common.annotation.Adapter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; + +@Slf4j +@Adapter +@RequiredArgsConstructor +public class LoadProductsByPopularityScheduler { + + private final LoadProductsByPopularityCachePort loadProductsByPopularityCachePort; + + // 4분 마다 캐시값 갱신 - cache put + @Scheduled(fixedRateString = "PT4M", initialDelayString = "PT1M") + public void loadProductsByPopularity() { + loadProductsByPopularityCachePort.updateProductsByPopularity(); + } +} diff --git a/src/main/java/com/ftm/server/application/port/in/post/LoadProductsByHashTagUseCase.java b/src/main/java/com/ftm/server/application/port/in/post/LoadProductsByHashTagUseCase.java new file mode 100644 index 0000000..b88c2d5 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/post/LoadProductsByHashTagUseCase.java @@ -0,0 +1,13 @@ +package com.ftm.server.application.port.in.post; + +import com.ftm.server.application.vo.post.LoadProductsByHashTagVoWrapper; +import com.ftm.server.common.annotation.UseCase; +import com.ftm.server.domain.enums.ProductHashtag; +import java.util.List; + +@UseCase +public interface LoadProductsByHashTagUseCase { + + LoadProductsByHashTagVoWrapper execute( + Long userId, List hashtagList, Integer limit, Double score); +} diff --git a/src/main/java/com/ftm/server/application/port/out/cache/LoadProductsByPopularityCachePort.java b/src/main/java/com/ftm/server/application/port/out/cache/LoadProductsByPopularityCachePort.java new file mode 100644 index 0000000..611f8b2 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/cache/LoadProductsByPopularityCachePort.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.port.out.cache; + +import com.ftm.server.application.vo.post.ProductIdAndScoreVo; +import java.util.List; + +public interface LoadProductsByPopularityCachePort { + + List loadProductsByPopularity(); + + List updateProductsByPopularity(); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductPort.java index a7930e1..ab76c2b 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductPort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductPort.java @@ -2,6 +2,8 @@ import com.ftm.server.application.query.FindByIdsQuery; import com.ftm.server.application.query.FindByPostIdQuery; +import com.ftm.server.application.query.FindByProductHashTagsQuery; +import com.ftm.server.application.vo.post.ProductIdAndScoreVo; import com.ftm.server.common.annotation.Port; import com.ftm.server.domain.entity.PostProduct; import java.util.List; @@ -14,4 +16,10 @@ public interface LoadPostProductPort { List loadPostProductsByIds(FindByIdsQuery query); List loadPostProductsByPostIds(FindByIdsQuery query); + + List loadPostProductsByHashTags(FindByProductHashTagsQuery query); + + List loadAllPostProduct(); + + List loadAllProductsByPopularity(); } diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadProductLikePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadProductLikePort.java index 1eb7743..c9c559f 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadProductLikePort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadProductLikePort.java @@ -1,10 +1,13 @@ package com.ftm.server.application.port.out.persistence.post; +import com.ftm.server.application.vo.post.LoadProductAndUserLikeVo; import com.ftm.server.common.annotation.Port; +import java.util.List; import java.util.Optional; @Port public interface LoadProductLikePort { + List findProductLikeByUser(Long userId, List productIds); Optional findOneByUserAndProduct(Long userId, Long postProductId); } diff --git a/src/main/java/com/ftm/server/application/query/FindByProductHashTagsQuery.java b/src/main/java/com/ftm/server/application/query/FindByProductHashTagsQuery.java new file mode 100644 index 0000000..e21fc3c --- /dev/null +++ b/src/main/java/com/ftm/server/application/query/FindByProductHashTagsQuery.java @@ -0,0 +1,12 @@ +package com.ftm.server.application.query; + +import com.ftm.server.domain.enums.ProductHashtag; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class FindByProductHashTagsQuery { + List productHashtagList; +} diff --git a/src/main/java/com/ftm/server/application/service/post/LoadProductsByHashTagService.java b/src/main/java/com/ftm/server/application/service/post/LoadProductsByHashTagService.java new file mode 100644 index 0000000..bfeda55 --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/LoadProductsByHashTagService.java @@ -0,0 +1,129 @@ +package com.ftm.server.application.service.post; + +import com.ftm.server.application.port.in.post.LoadProductsByHashTagUseCase; +import com.ftm.server.application.port.out.cache.LoadProductsByPopularityCachePort; +import com.ftm.server.application.port.out.persistence.post.LoadPostProductImagePort; +import com.ftm.server.application.port.out.persistence.post.LoadPostProductPort; +import com.ftm.server.application.port.out.persistence.post.LoadProductLikePort; +import com.ftm.server.application.query.FindByIdsQuery; +import com.ftm.server.application.query.FindByProductHashTagsQuery; +import com.ftm.server.application.vo.post.LoadProductAndUserLikeVo; +import com.ftm.server.application.vo.post.LoadProductsByHashTagVo; +import com.ftm.server.application.vo.post.LoadProductsByHashTagVoWrapper; +import com.ftm.server.application.vo.post.ProductIdAndScoreVo; +import com.ftm.server.domain.entity.PostProduct; +import com.ftm.server.domain.entity.PostProductImage; +import com.ftm.server.domain.enums.ProductHashtag; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LoadProductsByHashTagService implements LoadProductsByHashTagUseCase { + + private final LoadPostProductPort loadPostProductPort; + private final LoadProductLikePort loadProductLikePort; + private final LoadPostProductImagePort loadPostProductImagePort; + + private final LoadProductsByPopularityCachePort loadProductsByPopularityCachePort; + + @Override + public LoadProductsByHashTagVoWrapper execute( + Long userId, List hashtagList, Integer limit, Double score) { + + // 0. 인기순으로 정렬된 product id를 cache 에서 조회 + List productIdAndScoreList = + loadProductsByPopularityCachePort.loadProductsByPopularity(); + if (score != null) { + productIdAndScoreList = + productIdAndScoreList.stream().filter(a -> a.getScore() < score).toList(); + } + + if (productIdAndScoreList.size() == 0) { + return LoadProductsByHashTagVoWrapper.of(List.of(), false, 0.0); + } + + // 1. postProduct 해시태그 별 정보 조회 + List postProducts; + if (hashtagList == null || hashtagList.isEmpty()) { + postProducts = loadPostProductPort.loadAllPostProduct(); + } else { + postProducts = + loadPostProductPort.loadPostProductsByHashTags( + new FindByProductHashTagsQuery(hashtagList)); + } + + Map postProductMap = + postProducts.stream() + .collect( + Collectors.toMap( + PostProduct::getId, // key: productId + Function.identity() // value: 해당 PostProduct 객체 자체 + )); + + // 2. 대상 product id 추출 + score 순서대로 limit 만큼 커팅 + int beforeSize = productIdAndScoreList.size(); + Set tempProductIds = + postProducts.stream().map(PostProduct::getId).collect(Collectors.toSet()); + List productIdAndScoreVoList = + productIdAndScoreList.stream() + .filter(vo -> tempProductIds.contains(vo.getProductId())) // DB에 존재하는 상품만 + .limit(limit) // limit 만큼 자르기 + .toList(); + int finalSize = productIdAndScoreVoList.size(); + Double lastScore = + productIdAndScoreVoList.get(productIdAndScoreVoList.size() - 1).getScore(); + List productIds = + productIdAndScoreVoList.stream().map(ProductIdAndScoreVo::getProductId).toList(); + + // 3. 유저 좋아요 여부 확인 + List loadProductAndUserLikeVos = null; + if (userId == null) { + loadProductAndUserLikeVos = + productIds.stream() + .map(p -> new LoadProductAndUserLikeVo(p, null, false)) + .toList(); + } else { + loadProductAndUserLikeVos = + loadProductLikePort.findProductLikeByUser(userId, productIds); + } + Map productLikeMap = + loadProductAndUserLikeVos.stream() + .collect( + Collectors.toMap( + LoadProductAndUserLikeVo::getProductId, // key: productId + Function.identity() // value: 해당 PostProduct 객체 자체 + )); + + // 4. 이미지 가져오기 + List postProductImages = + loadPostProductImagePort.loadPostProductImagesByPostProductIds( + FindByIdsQuery.from(productIds)); + + Map productImageMap = + postProductImages.stream() + .collect( + Collectors.toMap( + PostProductImage::getPostProductId, // key: productId + Function.identity() // value: 해당 PostProduct 객체 자체 + )); + + // 5. 결과값 merge + List tempResult = + productIds.stream() + .map( + p -> + LoadProductsByHashTagVo.from( + postProductMap.get(p), + productLikeMap.get(p), + productImageMap.getOrDefault(p, null))) + .toList(); + + return LoadProductsByHashTagVoWrapper.of(tempResult, beforeSize > finalSize, lastScore); + } +} diff --git a/src/main/java/com/ftm/server/application/vo/post/LoadProductAndUserLikeVo.java b/src/main/java/com/ftm/server/application/vo/post/LoadProductAndUserLikeVo.java new file mode 100644 index 0000000..f47f180 --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/LoadProductAndUserLikeVo.java @@ -0,0 +1,12 @@ +package com.ftm.server.application.vo.post; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class LoadProductAndUserLikeVo { + private final Long productId; + private final Long userId; + private final Boolean likeYn; +} diff --git a/src/main/java/com/ftm/server/application/vo/post/LoadProductsByHashTagVo.java b/src/main/java/com/ftm/server/application/vo/post/LoadProductsByHashTagVo.java new file mode 100644 index 0000000..6cb3b22 --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/LoadProductsByHashTagVo.java @@ -0,0 +1,35 @@ +package com.ftm.server.application.vo.post; + +import com.ftm.server.common.consts.PropertiesHolder; +import com.ftm.server.domain.entity.PostProduct; +import com.ftm.server.domain.entity.PostProductImage; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class LoadProductsByHashTagVo { + + private final Long productId; + private final String productName; + private final String brand; + private final Long recommendedCount; + private final Long postId; + private final String productImage; + private final Boolean likeYn; + + public static LoadProductsByHashTagVo from( + PostProduct postProduct, LoadProductAndUserLikeVo vo, PostProductImage imageUrl) { + String productImage = + imageUrl == null ? PropertiesHolder.PRODUCT_DEFAULT_IMAGE : imageUrl.getObjectKey(); + productImage = PropertiesHolder.CDN_PATH + "/" + productImage; + return new LoadProductsByHashTagVo( + postProduct.getId(), + postProduct.getName(), + postProduct.getBrand(), + postProduct.getRecommendedCount(), + postProduct.getPostId(), + productImage, + vo.getLikeYn()); + } +} diff --git a/src/main/java/com/ftm/server/application/vo/post/LoadProductsByHashTagVoWrapper.java b/src/main/java/com/ftm/server/application/vo/post/LoadProductsByHashTagVoWrapper.java new file mode 100644 index 0000000..e8d43b9 --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/LoadProductsByHashTagVoWrapper.java @@ -0,0 +1,16 @@ +package com.ftm.server.application.vo.post; + +import java.util.List; +import lombok.Data; + +@Data +public class LoadProductsByHashTagVoWrapper { + private final List data; + private final Boolean hasNext; + private final Double lastScore; + + public static LoadProductsByHashTagVoWrapper of( + List data, Boolean hasNext, Double lastScore) { + return new LoadProductsByHashTagVoWrapper(data, hasNext, lastScore); + } +} diff --git a/src/main/java/com/ftm/server/application/vo/post/ProductIdAndScoreVo.java b/src/main/java/com/ftm/server/application/vo/post/ProductIdAndScoreVo.java new file mode 100644 index 0000000..00571d0 --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/ProductIdAndScoreVo.java @@ -0,0 +1,9 @@ +package com.ftm.server.application.vo.post; + +import lombok.Data; + +@Data +public class ProductIdAndScoreVo { + private final Long productId; + private final 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 27d4a54..14ca431 100644 --- a/src/main/java/com/ftm/server/common/consts/StaticConsts.java +++ b/src/main/java/com/ftm/server/common/consts/StaticConsts.java @@ -32,4 +32,7 @@ public class StaticConsts { 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'"; + + public static final String PRODUCTS_BY_POPULARITY_CACHE_NAME = "ftm:posts:products:ids:popular"; + public static final String PRODUCTS_BY_POPULARITY_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 a5376c6..83ea7aa 100644 --- a/src/main/java/com/ftm/server/infrastructure/cache/CaffeineCacheConfig.java +++ b/src/main/java/com/ftm/server/infrastructure/cache/CaffeineCacheConfig.java @@ -71,4 +71,12 @@ public CaffeineCacheManager cacheManagerForUserPickAllPopularPosts() { Caffeine.newBuilder().maximumSize(10).expireAfterWrite(2, TimeUnit.MINUTES)); return mgr; } + + @Bean("productsIdByPopularity") // 해시태그 상품 조회 - products id 인기순 정렬 용 캐시 + public CaffeineCacheManager cacheManagerForProductsIdByPopularity() { + CaffeineCacheManager mgr = new CaffeineCacheManager(); + mgr.setCaffeine( + Caffeine.newBuilder().maximumSize(10).expireAfterWrite(5, TimeUnit.MINUTES)); + return mgr; + } } diff --git a/src/main/java/com/ftm/server/infrastructure/security/SecurityConfig.java b/src/main/java/com/ftm/server/infrastructure/security/SecurityConfig.java index 30c4dac..006dc04 100644 --- a/src/main/java/com/ftm/server/infrastructure/security/SecurityConfig.java +++ b/src/main/java/com/ftm/server/infrastructure/security/SecurityConfig.java @@ -69,7 +69,8 @@ public class SecurityConfig { "/api/users/social", "/api/grooming/tests/submission", "/api/grooming/tests", - "api/users/me/recover" + "/api/users/me/recover", + "/api/posts/products" }; private static final String[] ANONYMOUS_MATCHERS = {"/docs/**"}; diff --git a/src/test/java/com/ftm/server/post/LoadProductsByHashTagTest.java b/src/test/java/com/ftm/server/post/LoadProductsByHashTagTest.java new file mode 100644 index 0000000..3b8d46f --- /dev/null +++ b/src/test/java/com/ftm/server/post/LoadProductsByHashTagTest.java @@ -0,0 +1,178 @@ +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.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +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.SavePostProductRequest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostRequest; +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.command.post.SavePostProductCommand; +import com.ftm.server.application.port.out.persistence.post.SavePostPort; +import com.ftm.server.application.port.out.persistence.post.SavePostProductPort; +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.PostProduct; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.enums.PostHashtag; +import com.ftm.server.domain.enums.ProductHashtag; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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.http.MediaType; +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 LoadProductsByHashTagTest extends BaseTest { + + @Autowired private SavePostPort savePostPort; + + @Autowired private SavePostProductPort savePostProductPort; + + private final List queryParameters = + List.of( + parameterWithName("size") + .description("한번 로딩 시 불러올 게시글의 개수") + .attributes(new Attributes.Attribute("constraint", "Integer")), + parameterWithName("lastScore") + .description("이전 로딩의 lastScore 입력") + .optional() + .attributes( + new Attributes.Attribute( + "constraint", + "이전 스크롤 로딩 응답값(lastScore) 필드 그대로 사용. 첫번째 스크롤인 경우 전달 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("예외 발생한 경우 null"), + fieldWithPath("data.data") + .type(ARRAY) + .description("응답 데이터. 데이터 없는 경우 빈 배열 반환") + .optional(), + fieldWithPath("data.data[].productId").type(NUMBER).description("상품 id"), + fieldWithPath("data.data[].productName").type(STRING).description("상품 이름"), + fieldWithPath("data.data[].productImage") + .type(STRING) + .description("상품 이미지 url"), + fieldWithPath("data.data[].likeYn") + .type(BOOLEAN) + .description("사용자 추천 버튼 누름 여부"), + fieldWithPath("data.data[].brand").type(STRING).description("브랜드"), + fieldWithPath("data.data[].recommendedCount").type(NUMBER).description("추천수"), + fieldWithPath("data.data[].postId").type(NUMBER).description("상품이 속한 게시글 id"), + fieldWithPath("data.hasNext").type(BOOLEAN).description("다음 스크롤에 데이터 존재 여부"), + fieldWithPath("data.lastScore").type(NUMBER).description("현재 스크롤의 마지막 점수")); + + private final List requestFields = + List.of( + fieldWithPath("hashTagList") + .type(ARRAY) + .optional() + .description( + "사용자가 선택한 해시태그 목록. 빈 배열이 전달되거나 field 전체가 전달되지 않을 경우, 전체 상품을 최신순으로 반환함.") + .attributes( + new Attributes.Attribute( + "constraint", + "게시글 상품 해시태그 목록 조회 api response 중 result.$.hashtags.name 값을 전달"))); + + private ResultActions getResultActions() throws Exception { + Map> req = new HashMap<>(); + req.put("hashTagList", List.of(ProductHashtag.HAND_CARE.name())); + return mockMvc.perform( + RestDocumentationRequestBuilders.post("/api/posts/products") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(req)) + .queryParam("size", String.valueOf(1))); + } + + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "loadPostProducts/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + requestFields(requestFields), + responseFields(responseFields), + queryParameters(queryParameters), + resource( + ResourceSnippetParameters.builder() + .tag("해시태그 추천") + .summary("해시태그 추천 - 해시태그 기반 상품 조회 api") + .description("해시태그 기반 상품 조회 api 입니다") + .requestFields(requestFields) + .responseFields(responseFields) + .build())); + } + + @Test + @Transactional + @DisplayName("테스트 성공") + public void test1() throws Exception { + // given + BaseTest.SessionAndUser sessionAndUser = createUserAndLoginAndReturnUser(); // 로그인 처리 + + User user = sessionAndUser.user(); + + // test 용 post 생성 + Post 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<>()))); + + savePostProductPort.savePostProducts( + List.of( + PostProduct.create( + SavePostProductCommand.from( + new SavePostProductRequest( + -1, + "상품 3번", + "이니스프리", + List.of( + ProductHashtag.HAND_CARE, + ProductHashtag.INCENSE))) + .withPostId(post.getId())))); + + // when + ResultActions resultActions = getResultActions(); + + // then + resultActions + .andExpect(status().is(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.data", hasSize(1))); + + // documentation + resultActions.andDo(getDocument(1)); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index f32288b..61a49a8 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -5,8 +5,8 @@ spring: datasource: url: jdbc:postgresql://localhost:5432/ftm_test_db - username: test - password: test + username: postgres + password: 123456 driver-class-name: org.postgresql.Driver jpa: From f7427adf3df1eaee92f6014c56c9a2bf70c3dc20 Mon Sep 17 00:00:00 2001 From: Minyoung Kim Date: Wed, 19 Nov 2025 20:14:35 +0900 Subject: [PATCH 2/2] chore : Update database credentials in application-test.yml --- src/test/resources/application-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 61a49a8..f32288b 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -5,8 +5,8 @@ spring: datasource: url: jdbc:postgresql://localhost:5432/ftm_test_db - username: postgres - password: 123456 + username: test + password: test driver-class-name: org.postgresql.Driver jpa: