Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/docs/asciidoc/post-api.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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[]


---
Original file line number Diff line number Diff line change
@@ -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)));
}
}
Original file line number Diff line number Diff line change
@@ -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<ProductHashtag> hashTagList;
}
Original file line number Diff line number Diff line change
@@ -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<LoadProductsByHashTagVo> data;
private final Boolean hasNext;
private final Double lastScore;

public static LoadProductsByHashTagResponse from(LoadProductsByHashTagVoWrapper wrapper) {
return new LoadProductsByHashTagResponse(
wrapper.getData(), wrapper.getHasNext(), wrapper.getLastScore());
}
}
Original file line number Diff line number Diff line change
@@ -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<ProductIdAndScoreVo> loadProductsByPopularity() {
return execute();
}

@Override
@CachePut(value = PRODUCTS_BY_POPULARITY_CACHE_NAME, key = PRODUCTS_BY_POPULARITY_CACHE_KEY_ALL)
public List<ProductIdAndScoreVo> updateProductsByPopularity() {
return execute();
}

public List<ProductIdAndScoreVo> execute() {
return loadPostProductPort.loadAllProductsByPopularity();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -241,6 +242,31 @@ public List<PostProduct> loadPostProductsByPostIds(FindByIdsQuery query) {
.toList();
}

@Override
public List<PostProduct> 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<PostProduct> loadAllPostProduct() {
return postProductRepository.findAllByLatest().stream()
.map(p -> postProductMapper.toDomainEntity(p))
.toList();
}

@Override
public List<ProductIdAndScoreVo> loadAllProductsByPopularity() {
return postProductRepository.findAllByPopularity();
}

@Override
public List<PostProductImage> loadPostProductImagesByPostProductIds(FindByIdsQuery query) {
List<PostProductImageJpaEntity> postProductImageJpaEntities =
Expand Down Expand Up @@ -405,4 +431,10 @@ public void saveProductLike(ProductLike productLike) {
public void deleteProductLike(Long productLikeId) {
productLikeRepository.deleteById(productLikeId);
}

@Override
public List<LoadProductAndUserLikeVo> findProductLikeByUser(
Long userId, List<Long> productIds) {
return productLikeRepository.findProductLikeByUser(userId, productIds);
}
}
Original file line number Diff line number Diff line change
@@ -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<ProductIdAndScoreVo> findAllByPopularity();
}
Original file line number Diff line number Diff line change
@@ -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<ProductIdAndScoreVo> 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<Double> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<PostProductJpaEntity, Long> {
public interface PostProductRepository
extends JpaRepository<PostProductJpaEntity, Long>, PostProductCustomRepository {

List<PostProductJpaEntity> findAllByPost(PostJpaEntity post);

Expand All @@ -19,4 +18,18 @@ public interface PostProductRepository extends JpaRepository<PostProductJpaEntit
@Modifying
@Query("DELETE FROM PostProductJpaEntity pp WHERE pp.id IN (:postProductIds)")
void deleteAllByIdInBatch(@Param("postProductIds") List<Long> postProductIds);

@Query("SELECT p FROM PostProductJpaEntity p order by p.createdAt desc ")
List<PostProductJpaEntity> findAllByLatest();

@Query(
value =
"""
SELECT *
FROM post_product
WHERE hashtags && CAST(:hashtags AS product_hashtag[])
ORDER BY created_at DESC
""",
nativeQuery = true)
List<PostProductJpaEntity> findByHashtags(@Param("hashtags") String[] hashtags);
}
Original file line number Diff line number Diff line change
@@ -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<ProductLikeJpaEntity, Long> {

@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<LoadProductAndUserLikeVo> findProductLikeByUser(
@Param("userId") Long userId, @Param("postProductIds") List<Long> postProductIds);

@Query(
"select p.id from ProductLikeJpaEntity p where p.user.id =:userId and p.postProduct.id =:productId")
Optional<Long> findByUserAndAndPostProduct(
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<ProductHashtag> hashtagList, Integer limit, Double score);
}
Original file line number Diff line number Diff line change
@@ -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<ProductIdAndScoreVo> loadProductsByPopularity();

List<ProductIdAndScoreVo> updateProductsByPopularity();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,4 +16,10 @@ public interface LoadPostProductPort {
List<PostProduct> loadPostProductsByIds(FindByIdsQuery query);

List<PostProduct> loadPostProductsByPostIds(FindByIdsQuery query);

List<PostProduct> loadPostProductsByHashTags(FindByProductHashTagsQuery query);

List<PostProduct> loadAllPostProduct();

List<ProductIdAndScoreVo> loadAllProductsByPopularity();
}
Loading
Loading