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
@@ -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
);
Comment on lines +52 to +55
Copy link

Copilot AI Jan 28, 2026

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.

Copilot uses AI. Check for mistakes.

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
Comment thread
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd BannerService.java

Repository: 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 -100

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 3515


🏁 Script executed:

rg "checkMonthlyReviewBanner" -A 30 -B 5

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 6586


🏁 Script executed:

rg "한달|monthly|Monthly" --type java

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 3315


🏁 Script executed:

rg "주문 완료 5일 후" -A 10 -B 5

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 1849


🏁 Script executed:

rg "findPendingMonthlyReviewItem" -A 20

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 3316


🏁 Script executed:

fd -e java -path "*/test/*" BannerService | head -5

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 235


🏁 Script executed:

rg "ONE_MONTH" -A 5 -B 5 --type java

Repository: IT-Cotato/12th-OnGil-BE

Length of output: 9555


상수명과 실제 임계값의 의도 불일치를 정합성 있게 정리해야 합니다.

5일 기준은 ReviewService의 ONE_MONTH_REVIEW_AVAILABLE_DAYS = 5 상수로 의도적으로 설정되어 있으며, BannerService와 ReviewService에서 일관되게 사용 중입니다. 다만 문제는:

  • 상수명이 "ONE_MONTH"인데 실제값은 5(일)
  • 배너 메시지는 "한달 후기를 작성해주세요!"이지만 실제 조건은 5일

변경할 경우 BannerService만 수정하면 ReviewService와 불일치하므로, 설계 의도를 명확히 한 뒤 양쪽을 함께 정합하세요:

  • 5일이 의도라면: 상수 이름을 MONTHLY_REVIEW_AVAILABLE_DAYS로 개명하고 메시지 의도 재검토
  • 1달이 의도라면: 상수값을 30으로 변경하고 양쪽 서비스 일괄 수정
🤖 Prompt for AI Agents
In `@src/main/java/com/ongil/backend/domain/banner/service/BannerService.java`
around lines 70 - 77, The constant name/value mismatch must be resolved by
deciding whether the threshold is 5 days or 30 days and applying the change
across services: if 5 days is correct, rename
ReviewService.ONE_MONTH_REVIEW_AVAILABLE_DAYS to MONTHLY_REVIEW_AVAILABLE_DAYS
(or MOVE the constant to a shared config/Constants class) and update
BannerService.checkMonthlyReviewBanner to reference that renamed/shared constant
and adjust the banner message text to reflect "available in 5 days" semantics;
if one-month (30 days) is intended, change the constant value to 30 in
ReviewService (or shared Constants), update
BannerService.checkMonthlyReviewBanner to use the same shared constant, and
update the banner message to "write your review after one month"
accordingly—ensure both services reference the same constant (e.g.,
ReviewService.ONE_MONTH_REVIEW_AVAILABLE_DAYS or new shared constant) and align
the displayed message with the chosen threshold.


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
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comment says this query is for “5일 전 이후 확정된 주문”, but the method accepts an arbitrary afterTime and is currently unused. Consider making the comment generic (e.g., “after a given time”), and/or removing unused repository methods from this PR to keep the repository surface minimal.

Copilot uses AI. Check for mistakes.

// 특정 사용자의 특정 상태 주문 조회
@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
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment “한달 이상 경과” is misleading here because this method simply filters by an arbitrary beforeTime (and the banner service currently passes 5 days). Please either generalize the comment or ensure the calling code passes an actual “one month” threshold so terminology matches behavior.

Copilot uses AI. Check for mistakes.
List<Order> findByUserIdAndStatusAndConfirmedAtBefore(
@Param("userId") Long userId,
@Param("status") OrderStatus status,
@Param("beforeTime") LocalDateTime beforeTime
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,15 @@ Page<Review> 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<Long> findReviewedOrderItemIds(
@Param("orderItemIds") List<Long> orderItemIds,
@Param("reviewType") ReviewType reviewType
);

// ReviewRepository.java

@Lock(LockModeType.PESSIMISTIC_WRITE)
Expand Down