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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.back.domain.order.order.entity.Order;
import com.back.domain.order.orderItem.entity.OrderItem;
import com.back.domain.product.product.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -17,4 +18,7 @@ public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
"JOIN FETCH oi.product " +
"WHERE oi.order = :order")
List<OrderItem> findByOrderWithProduct(@Param("order") Order order);

// 특정 상품의 총 누적 판매 수량
long countByProduct(Product product);
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ public class Product extends BaseEntity {

private Integer reviewCount; // 리뷰 개수

private int popularityScore = 0; // 인기 점수 필드

@Column(nullable = false)
private boolean isDeleted; // 논리 삭제 여부 (상품 삭제 시, 진짜 DB에서 삭제하냐 아니면 삭제 처리만 하냐 차이)

Expand All @@ -128,4 +130,9 @@ public class Product extends BaseEntity {
public int getDiscountPrice() {
return price - (price * discountRate / 100);
}

// 인기 점수 계산
public void updatePopularityScore(int score) {
this.popularityScore = score;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public ProductListResponse findProducts(
// 정렬 처리
if ("priceAsc".equals(sort)) query.orderBy(p.price.asc()); // 가격 낮은 순
else if ("priceDesc".equals(sort)) query.orderBy(p.price.desc()); // 가격 높은 순
else if ("popular".equals(sort)) query.orderBy(p.popularityScore.desc()); // 인기순
else query.orderBy(p.createDate.desc()); // 일단 기본은 신상품순으로 함.

// 전체 건수 조회 (페이징용)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.back.domain.product.product.scheduler;

import com.back.domain.order.orderItem.repository.OrderItemRepository;
import com.back.domain.product.product.entity.Product;
import com.back.domain.product.product.repository.ProductRepository;
import com.back.domain.wishlist.repository.WishlistRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class ProductPopularityScheduler {

private final ProductRepository productRepository;
private final OrderItemRepository orderItemRepository;
private final WishlistRepository wishlistRepository;

// 매일 한국 시간 기준 오전 10시 30분에 실행 (이후 실제 서비스 운영한다면 매일 00:20시에 실행되도록 수정)
@Scheduled(cron = "0 30 10 * * *", zone = "Asia/Seoul")
@Transactional
public void updatePopularityScores() {
log.info("상품 인기 점수 계산 스케줄러 시작");
List<Product> products = productRepository.findAll();

for (Product product : products) {
// 판매 수 계산 (가중치 60%)
long salesCount = orderItemRepository.countByProduct(product);

// 찜 수 계산 (가중치 20%)
long wishlistCount = wishlistRepository.countByProduct(product);

// 리뷰 평점 (가중치 20%)
Double averageRating = product.getAverageRating();
if (averageRating == null) {
averageRating = 0.0;
}

// 최종 점수 계산
int popularityScore = (int) (salesCount * 0.6 + wishlistCount * 0.2 + averageRating * 0.2);

// 상품에 점수 업데이트
product.updatePopularityScore(popularityScore);
}
log.info("{}개 상품에 대한 인기 점수 업데이트 완료.", products.size());
}
}
24 changes: 24 additions & 0 deletions src/main/java/com/back/domain/review/service/ReviewService.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ public ReviewResponseDto createReview(ReviewCreateRequestDto requestDto, User us
Review savedReview = reviewRepository.save(review);

log.info("리뷰 작성 완료 - 리뷰ID: {}", savedReview.getId());

// 상품 평점 및 리뷰 개수 업데이트
updateProductReviewStats(product);

return ReviewResponseDto.from(savedReview, false); // 작성자는 좋아요 안 누름
}

Expand Down Expand Up @@ -329,6 +333,9 @@ public ReviewResponseDto updateReview(Long reviewId, ReviewUpdateRequestDto requ

Review savedReview = reviewRepository.save(review);

// 상품 평점 및 리뷰 개수 업데이트
updateProductReviewStats(savedReview.getProduct());

// 사용자가 좋아요 눌렀는지 확인
boolean isLiked = reviewLikeRepository.findByReviewAndUserAndNotDeleted(savedReview, user).isPresent();

Expand Down Expand Up @@ -358,6 +365,9 @@ public void deleteReview(Long reviewId, User user) {
review.deleteReview();
reviewRepository.save(review);

// 상품 평점 및 리뷰 개수 업데이트
updateProductReviewStats(review.getProduct());

log.info("리뷰 삭제 완료 - 리뷰ID: {}", reviewId);
}

Expand Down Expand Up @@ -454,4 +464,18 @@ private Page<Review> getReviewsByType(Product product, ReviewListRequestDto.Revi
return reviewRepository.findByProductAndNotDeleted(product, pageable);
}
}

/**
* 상품의 평균 평점(averageRating)과 리뷰 개수(reviewCount) 업데이트
*/
private void updateProductReviewStats(Product product) {
Long reviewCount = reviewRepository.countByProductAndNotDeleted(product);
Double averageRating = reviewRepository.findAverageRatingByProduct(product).orElse(0.0);

product.setReviewCount(reviewCount.intValue()); // 상태 업데이트
product.setAverageRating(averageRating); // 상태 업데이트
productRepository.save(product); // DB 저장
log.info("상품 리뷰 통계 업데이트 완료 - 상품ID: {}, 리뷰개수: {}, 평균평점: {}",
product.getId(), reviewCount, averageRating);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import com.back.domain.wishlist.service.WishlistService;
import com.back.global.rsData.RsData;
import com.back.global.security.auth.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
Expand All @@ -21,6 +25,27 @@ public class WishlistController {

/** 찜 등록 */
@PostMapping
@Operation(
summary = "찜 등록",
responses = {
@ApiResponse(
responseCode = "200",
description = "찜 등록 성공",
content = @Content(
mediaType = "application/json",
schema = @Schema(
example = """
{
"resultCode": "200",
"msg": "상품이 위시리스트에 추가되었습니다.",
"data": "a1b2c3d4-e5f6-7890-1234-567890abcdef"
}
"""
)
)
)
}
)
public ResponseEntity<RsData<UUID>> addWishlist(
@PathVariable UUID productUuid,
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
Expand All @@ -30,6 +55,27 @@ public ResponseEntity<RsData<UUID>> addWishlist(

/** 찜 삭제 */
@DeleteMapping
@Operation(
summary = "찜 삭제",
responses = {
@ApiResponse(
responseCode = "200",
description = "찜 삭제 성공",
content = @Content(
mediaType = "application/json",
schema = @Schema(
example = """
{
"resultCode": "200",
"msg": "상품이 위시리스트에서 제거되었습니다.",
"data": "a1b2c3d4-e5f6-7890-1234-567890abcdef"
}
"""
)
)
)
}
)
public ResponseEntity<RsData<UUID>> removeWishlist(
@PathVariable UUID productUuid,
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
Expand All @@ -39,6 +85,27 @@ public ResponseEntity<RsData<UUID>> removeWishlist(

/** 상품별 찜 개수 조회 */
@GetMapping("/count")
@Operation(
summary = "상품별 찜 개수 조회",
responses = {
@ApiResponse(
responseCode = "200",
description = "상품별 찜 개수 조회 성공",
content = @Content(
mediaType = "application/json",
schema = @Schema(
example = """
{
"resultCode": "200",
"msg": "상품 찜 개수 조회 성공",
"data": 10
}
"""
)
)
)
}
)
public ResponseEntity<RsData<Long>> getWishlistCount(
@PathVariable UUID productUuid) {
Long count = wishlistService.getWishlistCount(productUuid);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.back.domain.wishlist.repository;

import com.back.domain.product.product.entity.Product;
import com.back.domain.wishlist.entity.Wishlist;
import org.springframework.data.jpa.repository.JpaRepository;

Expand All @@ -11,4 +12,7 @@ public interface WishlistRepository extends JpaRepository<Wishlist, Long> {
// 상품별 찜 개수 조회
Long countByProductId(Long productId);

// 특정 상품 해당하는 Wishlist(찜) 개수
long countByProduct(Product product);

}
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,29 @@ void getProducts_Success_EmptyResult() throws Exception {
.andExpect(jsonPath("$.data.products", hasSize(0)));
}

@Test
@DisplayName("인기순으로 상품 목록을 정렬하여 조회한다")
void getProducts_Success_SortByPopular() throws Exception {
// Given
productRepository.save(createSampleProduct(artistUser, category, List.of(tag1), "인기상품A", 10000, 100));
productRepository.save(createSampleProduct(artistUser, category, List.of(tag2), "인기상품C", 30000, 300));
productRepository.save(createSampleProduct(artistUser, category, List.of(tag1, tag2), "인기상품B", 20000, 200));

// When
ResultActions resultActions = mockMvc.perform(
get("/api/products")
.param("sort", "popular")
).andDo(print());

// Then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.products", hasSize(3)))
.andExpect(jsonPath("$.data.products[0].name").value("인기상품C"))
.andExpect(jsonPath("$.data.products[1].name").value("인기상품B"))
.andExpect(jsonPath("$.data.products[2].name").value("인기상품A"));
}

// ==================== 상품 수정 (Update) ====================

@Test
Expand Down Expand Up @@ -572,4 +595,10 @@ private Product createSampleProduct(User artist, Category category, List<Tag> ta
});
return product;
}

private Product createSampleProduct(User artist, Category category, List<Tag> tags, String name, int price, int popularityScore) {
Product product = createSampleProduct(artist, category, tags, name, price);
product.updatePopularityScore(popularityScore);
return product;
}
}