-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 마이페이지 맞춤형 배너 알림 조회 API 구현 #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DataResponse<BannerResponse>> getBanner( | ||
| @AuthenticationPrincipal Long userId | ||
| ) { | ||
| BannerResponse response = bannerService.getBanner(userId); | ||
| return ResponseEntity.ok(DataResponse.from(response)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| package com.ongil.backend.domain.banner.service; | ||
|
|
||
| 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; | ||
|
|
||
| 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 static final int MONTHLY_REVIEW_AVAILABLE_DAYS = 5; | ||
|
|
||
| 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순위: 한달 후기 미작성 (5일 경과 후 활성화) | ||
| BannerResponse monthlyReviewBanner = checkMonthlyReviewBanner(userId); | ||
| if (monthlyReviewBanner != null) { | ||
| return monthlyReviewBanner; | ||
| } | ||
|
|
||
| // 3순위: 매거진 유도 알림 | ||
| return createMagazineBanner(); | ||
| } | ||
|
|
||
| private BannerResponse checkInitialReviewBanner(Long userId) { | ||
| List<Order> confirmedOrders = orderRepository.findByUserIdAndStatus( | ||
| userId, | ||
| OrderStatus.CONFIRMED | ||
| ); | ||
|
|
||
| if (confirmedOrders.isEmpty()) { | ||
| return null; | ||
| } | ||
|
|
||
| // 모든 OrderItem ID 수집 | ||
| List<Long> allOrderItemIds = confirmedOrders.stream() | ||
| .flatMap(order -> order.getOrderItems().stream()) | ||
| .map(OrderItem::getId) | ||
| .collect(Collectors.toList()); | ||
|
|
||
| if (allOrderItemIds.isEmpty()) { | ||
| return null; | ||
| } | ||
|
|
||
| // 한번의 쿼리로 초기 리뷰 작성된 OrderItem ID 목록 조회 | ||
| Set<Long> reviewedOrderItemIds = reviewRepository | ||
| .findReviewedOrderItemIds(allOrderItemIds, ReviewType.INITIAL) | ||
| .stream() | ||
| .collect(Collectors.toSet()); | ||
|
|
||
| // 미작성 주문 찾기 | ||
| for (Order order : confirmedOrders) { | ||
| for (OrderItem item : order.getOrderItems()) { | ||
| if (!reviewedOrderItemIds.contains(item.getId())) { | ||
| return bannerConverter.toResponse( | ||
| BannerType.REVIEW_PROMPT, | ||
| "구매하신 상품은 어떠셨나요?", | ||
| "작성하러 가기", | ||
| "/review/write", | ||
| item.getId(), | ||
| true | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private BannerResponse checkMonthlyReviewBanner(Long userId) { | ||
| LocalDateTime availableDate = LocalDateTime.now().minusDays(MONTHLY_REVIEW_AVAILABLE_DAYS); | ||
|
|
||
| List<Order> orders = orderRepository.findByUserIdAndStatusAndConfirmedAtBefore( | ||
| userId, | ||
| OrderStatus.CONFIRMED, | ||
| availableDate | ||
| ); | ||
|
Comment on lines
+96
to
+103
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: fd BannerService.javaRepository: IT-Cotato/12th-OnGil-BE Length of output: 139 🏁 Script executed: cat -n src/main/java/com/ongil/backend/domain/banner/service/BannerService.java | head -100Repository: IT-Cotato/12th-OnGil-BE Length of output: 3515 🏁 Script executed: rg "checkMonthlyReviewBanner" -A 30 -B 5Repository: IT-Cotato/12th-OnGil-BE Length of output: 6586 🏁 Script executed: rg "한달|monthly|Monthly" --type javaRepository: IT-Cotato/12th-OnGil-BE Length of output: 3315 🏁 Script executed: rg "주문 완료 5일 후" -A 10 -B 5Repository: IT-Cotato/12th-OnGil-BE Length of output: 1849 🏁 Script executed: rg "findPendingMonthlyReviewItem" -A 20Repository: IT-Cotato/12th-OnGil-BE Length of output: 3316 🏁 Script executed: fd -e java -path "*/test/*" BannerService | head -5Repository: IT-Cotato/12th-OnGil-BE Length of output: 235 🏁 Script executed: rg "ONE_MONTH" -A 5 -B 5 --type javaRepository: IT-Cotato/12th-OnGil-BE Length of output: 9555 상수명과 실제 임계값의 의도 불일치를 정합성 있게 정리해야 합니다. 5일 기준은 ReviewService의
변경할 경우 BannerService만 수정하면 ReviewService와 불일치하므로, 설계 의도를 명확히 한 뒤 양쪽을 함께 정합하세요:
🤖 Prompt for AI Agents |
||
|
|
||
| if (orders.isEmpty()) { | ||
| return null; | ||
| } | ||
|
|
||
| // 모든 OrderItem ID 수집 | ||
| List<Long> allOrderItemIds = orders.stream() | ||
| .flatMap(order -> order.getOrderItems().stream()) | ||
| .map(OrderItem::getId) | ||
| .collect(Collectors.toList()); | ||
|
|
||
| if (allOrderItemIds.isEmpty()) { | ||
| return null; | ||
| } | ||
|
|
||
| // 한번의 쿼리로 한달 후기 작성된 OrderItem ID 목록 조회 | ||
| Set<Long> reviewedOrderItemIds = reviewRepository | ||
| .findReviewedOrderItemIds(allOrderItemIds, ReviewType.ONE_MONTH) | ||
| .stream() | ||
| .collect(Collectors.toSet()); | ||
|
|
||
| // 미작성 주문 찾기 | ||
| for (Order order : orders) { | ||
| for (OrderItem item : order.getOrderItems()) { | ||
| if (!reviewedOrderItemIds.contains(item.getId())) { | ||
| return bannerConverter.toResponse( | ||
| BannerType.MONTHLY_REVIEW_PROMPT, | ||
| "한달 후기를 작성해주세요!", | ||
| "작성하러 가기", | ||
| "/review/monthly/write", | ||
| item.getId(), | ||
| true | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private BannerResponse createMagazineBanner() { | ||
| return bannerConverter.toResponse( | ||
| BannerType.MAGAZINE, | ||
| "추천 매거진을 확인해보세요", | ||
| "보러가기", | ||
| "/magazine", | ||
| null, | ||
| true | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Order, Long> { | ||
|
|
||
| // 특정 사용자의 주문 확정 시간 범위로 주문 조회 | ||
| @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<Order> 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<Order> findByUserIdAndStatusAndConfirmedAtAfter( | ||
| @Param("userId") Long userId, | ||
| @Param("status") OrderStatus status, | ||
| @Param("afterTime") LocalDateTime afterTime | ||
| ); | ||
|
Comment on lines
+29
to
+39
|
||
|
|
||
| // 특정 사용자의 특정 상태 주문 조회 | ||
| @Query("SELECT o FROM Order o " + | ||
| "WHERE o.user.id = :userId " + | ||
| "AND o.orderStatus = :status " + | ||
| "ORDER BY o.confirmedAt DESC") | ||
| List<Order> 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") | ||
|
Comment on lines
+51
to
+56
|
||
| List<Order> findByUserIdAndStatusAndConfirmedAtBefore( | ||
| @Param("userId") Long userId, | ||
| @Param("status") OrderStatus status, | ||
| @Param("beforeTime") LocalDateTime beforeTime | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
findByUserIdAndStatus(...)loads all confirmed orders for the user, then scans them in memory. For users with many historical orders this can be expensive. Consider narrowing the query window (e.g., only recent confirmed orders for the “구매직후” banner) and/or using paging/limit so this endpoint stays fast.