From 770896b99ed3110d967a3c37ad0a1a5b3c2289d6 Mon Sep 17 00:00:00 2001 From: JO HYUNGJOON Date: Wed, 28 Jan 2026 20:51:48 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A7=9E=EC=B6=A4=ED=98=95=20=EB=B0=B0?= =?UTF-8?q?=EB=84=88=20=EC=95=8C=EB=A6=BC=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../banner/controller/BannerController.java | 33 +++++ .../banner/converter/BannerConverter.java | 22 +++ .../banner/dto/response/BannerResponse.java | 16 +++ .../domain/banner/enums/BannerType.java | 14 ++ .../domain/banner/service/BannerService.java | 132 ++++++++++++++++++ .../order/repository/OrderRepository.java | 54 +++++++ 6 files changed, 271 insertions(+) create mode 100644 src/main/java/com/ongil/backend/domain/banner/controller/BannerController.java create mode 100644 src/main/java/com/ongil/backend/domain/banner/converter/BannerConverter.java create mode 100644 src/main/java/com/ongil/backend/domain/banner/dto/response/BannerResponse.java create mode 100644 src/main/java/com/ongil/backend/domain/banner/enums/BannerType.java create mode 100644 src/main/java/com/ongil/backend/domain/banner/service/BannerService.java diff --git a/src/main/java/com/ongil/backend/domain/banner/controller/BannerController.java b/src/main/java/com/ongil/backend/domain/banner/controller/BannerController.java new file mode 100644 index 0000000..71e91f4 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/banner/controller/BannerController.java @@ -0,0 +1,33 @@ +package com.ongil.backend.domain.banner.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ongil.backend.domain.banner.dto.response.BannerResponse; +import com.ongil.backend.domain.banner.service.BannerService; +import com.ongil.backend.global.common.dto.DataResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Banner", description = "배너 알림 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/banner") +public class BannerController { + + private final BannerService bannerService; + + @GetMapping + @Operation(summary = "배너 조회 API", description = "토큰 필요. 현재 사용자에게 보여줄 배너를 조회합니다. 구매 직후 리뷰 유도, 한달 후기 유도, 매거진 추천 중 우선순위에 따라 반환됩니다.") + public ResponseEntity> getBanner( + @AuthenticationPrincipal Long userId + ) { + BannerResponse response = bannerService.getBanner(userId); + return ResponseEntity.ok(DataResponse.from(response)); + } +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/banner/converter/BannerConverter.java b/src/main/java/com/ongil/backend/domain/banner/converter/BannerConverter.java new file mode 100644 index 0000000..6f5dd92 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/banner/converter/BannerConverter.java @@ -0,0 +1,22 @@ +package com.ongil.backend.domain.banner.converter; + +import org.springframework.stereotype.Component; + +import com.ongil.backend.domain.banner.dto.response.BannerResponse; +import com.ongil.backend.domain.banner.enums.BannerType; + +@Component +public class BannerConverter { + + public BannerResponse toResponse(BannerType type, String title, String buttonText, + String targetUrl, Long targetId, boolean enabled) { + return BannerResponse.builder() + .type(type) + .title(title) + .buttonText(buttonText) + .targetUrl(targetUrl) + .targetId(targetId) + .enabled(enabled) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/banner/dto/response/BannerResponse.java b/src/main/java/com/ongil/backend/domain/banner/dto/response/BannerResponse.java new file mode 100644 index 0000000..b78cc1c --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/banner/dto/response/BannerResponse.java @@ -0,0 +1,16 @@ +package com.ongil.backend.domain.banner.dto.response; + +import com.ongil.backend.domain.banner.enums.BannerType; + +import lombok.Builder; + +@Builder +public record BannerResponse( + BannerType type, + String title, + String buttonText, + String targetUrl, + Long targetId, + boolean enabled +) { +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/banner/enums/BannerType.java b/src/main/java/com/ongil/backend/domain/banner/enums/BannerType.java new file mode 100644 index 0000000..6aa23b9 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/banner/enums/BannerType.java @@ -0,0 +1,14 @@ +package com.ongil.backend.domain.banner.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum BannerType { + MAGAZINE("매거진 유도"), + REVIEW_PROMPT("구매 직후 리뷰 작성 유도"), + MONTHLY_REVIEW_PROMPT("한달 후 리뷰 작성 유도"); + + private final String description; +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java b/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java new file mode 100644 index 0000000..a207b8e --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java @@ -0,0 +1,132 @@ +package com.ongil.backend.domain.banner.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ongil.backend.domain.banner.converter.BannerConverter; +import com.ongil.backend.domain.banner.dto.response.BannerResponse; +import com.ongil.backend.domain.banner.enums.BannerType; +import com.ongil.backend.domain.order.entity.Order; +import com.ongil.backend.domain.order.entity.OrderItem; +import com.ongil.backend.domain.order.enums.OrderStatus; +import com.ongil.backend.domain.order.repository.OrderRepository; +import com.ongil.backend.domain.review.enums.ReviewType; +import com.ongil.backend.domain.review.repository.ReviewRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BannerService { + + private final OrderRepository orderRepository; + private final ReviewRepository reviewRepository; + private final BannerConverter bannerConverter; + + public BannerResponse getBanner(Long userId) { + // 1순위: 구매직후 후기 미작성 + BannerResponse initialReviewBanner = checkInitialReviewBanner(userId); + if (initialReviewBanner != null) { + return initialReviewBanner; + } + + // 2순위: 한달 후 후기 미작성 + BannerResponse monthlyReviewBanner = checkMonthlyReviewBanner(userId); + if (monthlyReviewBanner != null) { + return monthlyReviewBanner; + } + + // 3순위: 매거진 유도 알림 + return createMagazineBanner(); + } + + private BannerResponse checkInitialReviewBanner(Long userId) { + List confirmedOrders = orderRepository.findByUserIdAndStatus( + userId, + OrderStatus.CONFIRMED + ); + + for (Order order : confirmedOrders) { + OrderItem pendingItem = findPendingInitialReviewItem(order); + if (pendingItem != null) { + return bannerConverter.toResponse( + BannerType.REVIEW_PROMPT, + "구매하신 상품은 어떠셨나요?", + "작성하러 가기", + "/review/write", + order.getId(), + true + ); + } + } + + return null; + } + + private BannerResponse checkMonthlyReviewBanner(Long userId) { + LocalDateTime fiveDaysAgo = LocalDateTime.now().minusDays(5); + + List orders = orderRepository.findByUserIdAndStatusAndConfirmedAtBefore( + userId, + OrderStatus.CONFIRMED, + fiveDaysAgo + ); + + for (Order order : orders) { + OrderItem pendingItem = findPendingMonthlyReviewItem(order); + if (pendingItem != null) { + return bannerConverter.toResponse( + BannerType.MONTHLY_REVIEW_PROMPT, + "한달 후기를 작성해주세요!", + "작성하러 가기", + "/review/monthly/write", + order.getId(), + true + ); + } + } + + return null; + } + + private BannerResponse createMagazineBanner() { + return bannerConverter.toResponse( + BannerType.MAGAZINE, + "추천 매거진을 확인해보세요", + "보러가기", + "/magazine", + null, + true + ); + } + + private OrderItem findPendingInitialReviewItem(Order order) { + for (OrderItem item : order.getOrderItems()) { + boolean hasInitialReview = reviewRepository.existsByOrderItemIdAndReviewType( + item.getId(), + ReviewType.INITIAL + ); + if (!hasInitialReview) { + return item; + } + } + return null; + } + + private OrderItem findPendingMonthlyReviewItem(Order order) { + for (OrderItem item : order.getOrderItems()) { + boolean hasMonthlyReview = reviewRepository.existsByOrderItemIdAndReviewType( + item.getId(), + ReviewType.ONE_MONTH + ); + if (!hasMonthlyReview) { + return item; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/order/repository/OrderRepository.java b/src/main/java/com/ongil/backend/domain/order/repository/OrderRepository.java index c0c30c0..ff3525b 100644 --- a/src/main/java/com/ongil/backend/domain/order/repository/OrderRepository.java +++ b/src/main/java/com/ongil/backend/domain/order/repository/OrderRepository.java @@ -1,8 +1,62 @@ package com.ongil.backend.domain.order.repository; +import java.time.LocalDateTime; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.ongil.backend.domain.order.entity.Order; +import com.ongil.backend.domain.order.enums.OrderStatus; public interface OrderRepository extends JpaRepository { + + // 특정 사용자의 주문 확정 시간 범위로 주문 조회 + @Query("SELECT o FROM Order o " + + "WHERE o.user.id = :userId " + + "AND o.orderStatus = :status " + + "AND o.confirmedAt >= :startTime " + + "AND o.confirmedAt < :endTime " + + "ORDER BY o.confirmedAt DESC") + List findByUserIdAndStatusAndConfirmedAtBetween( + @Param("userId") Long userId, + @Param("status") OrderStatus status, + @Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime + ); + + // 특정 사용자의 주문 확정 시간 이후 주문 조회 (5일 전 이후 확정된 주문) + @Query("SELECT o FROM Order o " + + "WHERE o.user.id = :userId " + + "AND o.orderStatus = :status " + + "AND o.confirmedAt >= :afterTime " + + "ORDER BY o.confirmedAt ASC") + List findByUserIdAndStatusAndConfirmedAtAfter( + @Param("userId") Long userId, + @Param("status") OrderStatus status, + @Param("afterTime") LocalDateTime afterTime + ); + + // 특정 사용자의 특정 상태 주문 조회 + @Query("SELECT o FROM Order o " + + "WHERE o.user.id = :userId " + + "AND o.orderStatus = :status " + + "ORDER BY o.confirmedAt DESC") + List findByUserIdAndStatus( + @Param("userId") Long userId, + @Param("status") OrderStatus status + ); + + // 특정 사용자의 주문 확정 시간 이전 주문 조회 (한달 이상 경과) + @Query("SELECT o FROM Order o " + + "WHERE o.user.id = :userId " + + "AND o.orderStatus = :status " + + "AND o.confirmedAt <= :beforeTime " + + "ORDER BY o.confirmedAt DESC") + List findByUserIdAndStatusAndConfirmedAtBefore( + @Param("userId") Long userId, + @Param("status") OrderStatus status, + @Param("beforeTime") LocalDateTime beforeTime + ); } \ No newline at end of file From 640868b4e1e4f282662678167eabec7d3cc9644a Mon Sep 17 00:00:00 2001 From: JO HYUNGJOON Date: Wed, 28 Jan 2026 21:10:07 +0900 Subject: [PATCH 2/3] =?UTF-8?q?N+1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20=EB=B0=8F=20=EB=B3=80=EC=88=98=EB=AA=85=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20-=20coderabbit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/banner/service/BannerService.java | 120 +++++++++++------- .../review/repository/ReviewRepository.java | 9 ++ 2 files changed, 80 insertions(+), 49 deletions(-) diff --git a/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java b/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java index a207b8e..e20c6fe 100644 --- a/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java +++ b/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java @@ -2,6 +2,8 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,6 +25,8 @@ @Transactional(readOnly = true) public class BannerService { + private static final int MONTHLY_REVIEW_AVAILABLE_DAYS = 5; + private final OrderRepository orderRepository; private final ReviewRepository reviewRepository; private final BannerConverter bannerConverter; @@ -34,7 +38,7 @@ public BannerResponse getBanner(Long userId) { return initialReviewBanner; } - // 2순위: 한달 후 후기 미작성 + // 2순위: 한달 후기 미작성 (5일 경과 후 활성화) BannerResponse monthlyReviewBanner = checkMonthlyReviewBanner(userId); if (monthlyReviewBanner != null) { return monthlyReviewBanner; @@ -50,17 +54,39 @@ private BannerResponse checkInitialReviewBanner(Long userId) { OrderStatus.CONFIRMED ); + if (confirmedOrders.isEmpty()) { + return null; + } + + // 모든 OrderItem ID 수집 + List allOrderItemIds = confirmedOrders.stream() + .flatMap(order -> order.getOrderItems().stream()) + .map(OrderItem::getId) + .collect(Collectors.toList()); + + if (allOrderItemIds.isEmpty()) { + return null; + } + + // 한번의 쿼리로 초기 리뷰 작성된 OrderItem ID 목록 조회 + Set reviewedOrderItemIds = reviewRepository + .findReviewedOrderItemIds(allOrderItemIds, ReviewType.INITIAL) + .stream() + .collect(Collectors.toSet()); + + // 미작성 주문 찾기 for (Order order : confirmedOrders) { - OrderItem pendingItem = findPendingInitialReviewItem(order); - if (pendingItem != null) { - return bannerConverter.toResponse( - BannerType.REVIEW_PROMPT, - "구매하신 상품은 어떠셨나요?", - "작성하러 가기", - "/review/write", - order.getId(), - true - ); + for (OrderItem item : order.getOrderItems()) { + if (!reviewedOrderItemIds.contains(item.getId())) { + return bannerConverter.toResponse( + BannerType.REVIEW_PROMPT, + "구매하신 상품은 어떠셨나요?", + "작성하러 가기", + "/review/write", + order.getId(), + true + ); + } } } @@ -68,25 +94,47 @@ private BannerResponse checkInitialReviewBanner(Long userId) { } private BannerResponse checkMonthlyReviewBanner(Long userId) { - LocalDateTime fiveDaysAgo = LocalDateTime.now().minusDays(5); + LocalDateTime availableDate = LocalDateTime.now().minusDays(MONTHLY_REVIEW_AVAILABLE_DAYS); List orders = orderRepository.findByUserIdAndStatusAndConfirmedAtBefore( userId, OrderStatus.CONFIRMED, - fiveDaysAgo + availableDate ); + if (orders.isEmpty()) { + return null; + } + + // 모든 OrderItem ID 수집 + List allOrderItemIds = orders.stream() + .flatMap(order -> order.getOrderItems().stream()) + .map(OrderItem::getId) + .collect(Collectors.toList()); + + if (allOrderItemIds.isEmpty()) { + return null; + } + + // 한번의 쿼리로 한달 후기 작성된 OrderItem ID 목록 조회 + Set reviewedOrderItemIds = reviewRepository + .findReviewedOrderItemIds(allOrderItemIds, ReviewType.ONE_MONTH) + .stream() + .collect(Collectors.toSet()); + + // 미작성 주문 찾기 for (Order order : orders) { - OrderItem pendingItem = findPendingMonthlyReviewItem(order); - if (pendingItem != null) { - return bannerConverter.toResponse( - BannerType.MONTHLY_REVIEW_PROMPT, - "한달 후기를 작성해주세요!", - "작성하러 가기", - "/review/monthly/write", - order.getId(), - true - ); + for (OrderItem item : order.getOrderItems()) { + if (!reviewedOrderItemIds.contains(item.getId())) { + return bannerConverter.toResponse( + BannerType.MONTHLY_REVIEW_PROMPT, + "한달 후기를 작성해주세요!", + "작성하러 가기", + "/review/monthly/write", + order.getId(), + true + ); + } } } @@ -103,30 +151,4 @@ private BannerResponse createMagazineBanner() { true ); } - - private OrderItem findPendingInitialReviewItem(Order order) { - for (OrderItem item : order.getOrderItems()) { - boolean hasInitialReview = reviewRepository.existsByOrderItemIdAndReviewType( - item.getId(), - ReviewType.INITIAL - ); - if (!hasInitialReview) { - return item; - } - } - return null; - } - - private OrderItem findPendingMonthlyReviewItem(Order order) { - for (OrderItem item : order.getOrderItems()) { - boolean hasMonthlyReview = reviewRepository.existsByOrderItemIdAndReviewType( - item.getId(), - ReviewType.ONE_MONTH - ); - if (!hasMonthlyReview) { - return item; - } - } - return null; - } } \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/review/repository/ReviewRepository.java b/src/main/java/com/ongil/backend/domain/review/repository/ReviewRepository.java index b5fecda..794269e 100644 --- a/src/main/java/com/ongil/backend/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/ongil/backend/domain/review/repository/ReviewRepository.java @@ -177,6 +177,15 @@ Page findByUserIdAndReviewStatusAndReviewType( // OrderItem에 대해 이미 작성된 리뷰 타입 확인 boolean existsByOrderItemIdAndReviewType(Long orderItemId, ReviewType reviewType); + // N+1 문제 해결: 여러 OrderItem에 대해 리뷰 작성된 ID 목록 한번에 조회 + @Query("SELECT r.orderItem.id FROM Review r " + + "WHERE r.orderItem.id IN :orderItemIds " + + "AND r.reviewType = :reviewType") + List findReviewedOrderItemIds( + @Param("orderItemIds") List orderItemIds, + @Param("reviewType") ReviewType reviewType + ); + // ReviewRepository.java @Lock(LockModeType.PESSIMISTIC_WRITE) From 7fcb5bed4052aeb0d999413b6b4f2cf62f2b0fc1 Mon Sep 17 00:00:00 2001 From: JO HYUNGJOON Date: Wed, 28 Jan 2026 21:58:03 +0900 Subject: [PATCH 3/3] =?UTF-8?q?targetId=EB=A1=9C=20orderItemId=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ongil/backend/domain/banner/service/BannerService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java b/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java index e20c6fe..f1e9b4f 100644 --- a/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java +++ b/src/main/java/com/ongil/backend/domain/banner/service/BannerService.java @@ -83,7 +83,7 @@ private BannerResponse checkInitialReviewBanner(Long userId) { "구매하신 상품은 어떠셨나요?", "작성하러 가기", "/review/write", - order.getId(), + item.getId(), true ); } @@ -131,7 +131,7 @@ private BannerResponse checkMonthlyReviewBanner(Long userId) { "한달 후기를 작성해주세요!", "작성하러 가기", "/review/monthly/write", - order.getId(), + item.getId(), true ); }