From 78260766fb580c1f12c758a6e3cc0e0f8115e682 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 11 May 2026 17:58:08 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20KST=20=ED=83=80=EC=9E=84=EC=A1=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @EnableScheduling 활성화 위한 SchedulerConfig 신설 - Clock Bean을 Asia/Seoul로 등록 (코드 레벨 타임존 보장) - Dockerfile에 ENV TZ=Asia/Seoul 추가 (JVM 기본 타임존) --- Dockerfile | 2 ++ .../global/config/SchedulerConfig.java | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/main/java/com/catchtable/global/config/SchedulerConfig.java diff --git a/Dockerfile b/Dockerfile index b37f636..d01591c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM eclipse-temurin:21-jre +ENV TZ=Asia/Seoul + WORKDIR /app COPY build/libs/*.jar app.jar diff --git a/src/main/java/com/catchtable/global/config/SchedulerConfig.java b/src/main/java/com/catchtable/global/config/SchedulerConfig.java new file mode 100644 index 0000000..9977779 --- /dev/null +++ b/src/main/java/com/catchtable/global/config/SchedulerConfig.java @@ -0,0 +1,23 @@ +package com.catchtable.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.time.Clock; +import java.time.ZoneId; + +@Configuration +@EnableScheduling +public class SchedulerConfig { + + /** + * 스케줄러 및 시간 비교 로직에서 사용할 Clock. + * KST(Asia/Seoul) 기준으로 고정하여 Docker 컨테이너 기본 타임존(UTC) 영향을 받지 않도록 한다. + * LocalDateTime.now(clock) 형태로 사용하면 타임존 일관성과 테스트 가능성이 모두 확보된다. + */ + @Bean + public Clock clock() { + return Clock.system(ZoneId.of("Asia/Seoul")); + } +} From bd6ae475667cbb782789555e3e54d813b7ec2162 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 11 May 2026 18:04:17 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=A7=8C=EB=A3=8C=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CouponTemplate.expiredAt 경과한 UNUSED 쿠폰을 5분 주기로 EXPIRED 전환 - JPQL bulk UPDATE 사용 (서브쿼리 패턴으로 JOIN 우회) --- .../coupon/repository/CouponRepository.java | 13 +++++++ .../scheduler/CouponExpirationScheduler.java | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/main/java/com/catchtable/coupon/scheduler/CouponExpirationScheduler.java diff --git a/src/main/java/com/catchtable/coupon/repository/CouponRepository.java b/src/main/java/com/catchtable/coupon/repository/CouponRepository.java index 9002fd1..5f223d5 100644 --- a/src/main/java/com/catchtable/coupon/repository/CouponRepository.java +++ b/src/main/java/com/catchtable/coupon/repository/CouponRepository.java @@ -2,9 +2,11 @@ import com.catchtable.coupon.entity.Coupon; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; public interface CouponRepository extends JpaRepository { @@ -13,4 +15,15 @@ public interface CouponRepository extends JpaRepository { List findAllByUserId(@Param("userId") Long userId); boolean existsByUserIdAndCouponTemplateId(Long userId, Long couponTemplateId); + + /** + * UNUSED 상태의 쿠폰 중 템플릿 만료 시각이 지난 것을 일괄 EXPIRED로 전환한다. + * JPQL bulk UPDATE는 JOIN 직접 불가하므로 서브쿼리로 매칭한다. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Coupon c SET c.status = com.catchtable.coupon.entity.CouponStatus.EXPIRED " + + "WHERE c.status = com.catchtable.coupon.entity.CouponStatus.UNUSED " + + "AND c.isDeleted = false " + + "AND c.id IN (SELECT c2.id FROM Coupon c2 WHERE c2.couponTemplate.expiredAt < :now)") + int expireCoupons(@Param("now") LocalDateTime now); } diff --git a/src/main/java/com/catchtable/coupon/scheduler/CouponExpirationScheduler.java b/src/main/java/com/catchtable/coupon/scheduler/CouponExpirationScheduler.java new file mode 100644 index 0000000..09555e2 --- /dev/null +++ b/src/main/java/com/catchtable/coupon/scheduler/CouponExpirationScheduler.java @@ -0,0 +1,35 @@ +package com.catchtable.coupon.scheduler; + +import com.catchtable.coupon.repository.CouponRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponExpirationScheduler { + + private final CouponRepository couponRepository; + private final Clock clock; + + /** + * 5분 주기로 만료된 쿠폰을 EXPIRED 상태로 전환한다. + * 벌크 UPDATE 단일 쿼리로 처리하므로 트랜잭션 부담이 작다. + */ + @Scheduled(cron = "0 */5 * * * *") + @Transactional + public void expireCoupons() { + LocalDateTime now = LocalDateTime.now(clock); + int affected = couponRepository.expireCoupons(now); + + if (affected > 0) { + log.info("[쿠폰 만료] {}건 EXPIRED 처리 완료", affected); + } + } +} From 21b07e1abb68b39ec6cb64cd6467f831f898eb6c Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 11 May 2026 18:04:45 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20NOSHOW=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A0=84=ED=99=98=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 예약 시각 30분 경과한 CONFIRMED 예약을 10분 주기로 NOSHOW 전환 - 동시성 방어: 처리 직전 status=CONFIRMED 재확인으로 race 방지 - 트리거(Scheduler)와 비즈니스 로직(Service) 분리 --- .../repository/ReservationRepository.java | 14 +++++ .../scheduler/ReservationStatusScheduler.java | 27 +++++++++ .../service/ReservationStatusService.java | 56 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 src/main/java/com/catchtable/reservation/scheduler/ReservationStatusScheduler.java create mode 100644 src/main/java/com/catchtable/reservation/service/ReservationStatusService.java diff --git a/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java b/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java index 1cd226f..a13caa4 100644 --- a/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java +++ b/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java @@ -29,4 +29,18 @@ List findReminderTargets( @Query("SELECT r FROM Reservation r JOIN FETCH r.user u JOIN FETCH r.storeRemain sr JOIN FETCH sr.store s WHERE r.id = :id") Optional findByIdWithUserAndStoreRemainAndStore(@Param("id") Long id); + + /** + * NOSHOW 자동 전환 대상 조회. + * status = CONFIRMED 이고, 예약 시각(remainDate + remainTime)이 기준 시각(now - 30분) 이전인 건. + * remainDate/remainTime이 분리 저장이라 OR 조건으로 비교한다. + */ + @Query("SELECT r FROM Reservation r JOIN FETCH r.user u JOIN FETCH r.storeRemain sr JOIN FETCH sr.store s " + + "WHERE r.status = :status " + + "AND (sr.remainDate < :date " + + " OR (sr.remainDate = :date AND sr.remainTime <= :time))") + List findNoshowTargets( + @Param("status") ReservationStatus status, + @Param("date") LocalDate date, + @Param("time") LocalTime time); } diff --git a/src/main/java/com/catchtable/reservation/scheduler/ReservationStatusScheduler.java b/src/main/java/com/catchtable/reservation/scheduler/ReservationStatusScheduler.java new file mode 100644 index 0000000..d20ffee --- /dev/null +++ b/src/main/java/com/catchtable/reservation/scheduler/ReservationStatusScheduler.java @@ -0,0 +1,27 @@ +package com.catchtable.reservation.scheduler; + +import com.catchtable.reservation.service.ReservationStatusService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReservationStatusScheduler { + + private final ReservationStatusService reservationStatusService; + + /** + * 10분 주기로 NOSHOW 자동 전환 트리거. + * 실제 비즈니스 로직은 ReservationStatusService가 담당. + */ + @Scheduled(cron = "0 */10 * * * *") + public void transitionToNoshow() { + int transitioned = reservationStatusService.transitionExpiredConfirmedToNoshow(); + if (transitioned > 0) { + log.info("[NOSHOW 자동 전환] {}건 처리 완료", transitioned); + } + } +} diff --git a/src/main/java/com/catchtable/reservation/service/ReservationStatusService.java b/src/main/java/com/catchtable/reservation/service/ReservationStatusService.java new file mode 100644 index 0000000..ad58c20 --- /dev/null +++ b/src/main/java/com/catchtable/reservation/service/ReservationStatusService.java @@ -0,0 +1,56 @@ +package com.catchtable.reservation.service; + +import com.catchtable.reservation.entity.Reservation; +import com.catchtable.reservation.entity.ReservationStatus; +import com.catchtable.reservation.repository.ReservationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +/** + * 예약 상태 자동 전이 비즈니스 로직. + * 스케줄러는 트리거만 담당하고, 실제 로직은 이 서비스가 가진다. + * 테스트 시 Clock을 가짜로 주입하여 시간 의존성을 통제 가능. + */ +@Service +@RequiredArgsConstructor +public class ReservationStatusService { + + private static final int NOSHOW_THRESHOLD_MINUTES = 30; + + private final ReservationRepository reservationRepository; + private final Clock clock; + + /** + * 예약 시각이 30분 이상 지났는데 CONFIRMED 상태로 남아있는 예약을 NOSHOW로 전환한다. + * status = CONFIRMED 재확인으로 사용자가 동시에 VISITED 호출하는 경우 race 방지. + * + * @return 전환된 건수 + */ + @Transactional + public int transitionExpiredConfirmedToNoshow() { + LocalDateTime threshold = LocalDateTime.now(clock).minusMinutes(NOSHOW_THRESHOLD_MINUTES); + LocalDate date = threshold.toLocalDate(); + LocalTime time = threshold.toLocalTime(); + + List targets = reservationRepository.findNoshowTargets( + ReservationStatus.CONFIRMED, date, time); + + int transitioned = 0; + for (Reservation reservation : targets) { + if (reservation.getStatus() != ReservationStatus.CONFIRMED) { + continue; + } + + reservation.changeStatus(ReservationStatus.NOSHOW); + transitioned++; + } + return transitioned; + } +} From 5a384d277091dc62e424ea795ba387b67b706b72 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 11 May 2026 18:06:44 +0900 Subject: [PATCH 04/18] =?UTF-8?q?refactor:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EB=A6=AC=EB=A7=88=EC=9D=B8=EB=93=9C=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=9D=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?in-app=20=EC=95=8C=EB=A6=BC=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 발송 시점: 예약 30분 전 → 1시간 전 변경 - 발송 채널: EmailService 직접 호출 → 이벤트 + 리스너 패턴 - 다른 예약 알림(확정/취소/변경/방문)과 동일 패턴으로 통일 --- .../event/ReservationReminderEvent.java | 14 +++++ ...servationReminderNotificationListener.java | 51 +++++++++++++++++++ .../ReservationReminderScheduler.java | 39 +++++++------- 3 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/catchtable/notification/event/ReservationReminderEvent.java create mode 100644 src/main/java/com/catchtable/notification/listener/ReservationReminderNotificationListener.java diff --git a/src/main/java/com/catchtable/notification/event/ReservationReminderEvent.java b/src/main/java/com/catchtable/notification/event/ReservationReminderEvent.java new file mode 100644 index 0000000..679f903 --- /dev/null +++ b/src/main/java/com/catchtable/notification/event/ReservationReminderEvent.java @@ -0,0 +1,14 @@ +package com.catchtable.notification.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ReservationReminderEvent { + private final Long reservationId; + private final Long userId; + private final String storeName; + private final String remainDate; + private final String remainTime; +} diff --git a/src/main/java/com/catchtable/notification/listener/ReservationReminderNotificationListener.java b/src/main/java/com/catchtable/notification/listener/ReservationReminderNotificationListener.java new file mode 100644 index 0000000..d4670fc --- /dev/null +++ b/src/main/java/com/catchtable/notification/listener/ReservationReminderNotificationListener.java @@ -0,0 +1,51 @@ +package com.catchtable.notification.listener; + +import com.catchtable.global.exception.CustomException; +import com.catchtable.global.exception.ErrorCode; +import com.catchtable.notification.entity.NotificationType; +import com.catchtable.notification.event.ReservationReminderEvent; +import com.catchtable.notification.service.NotificationService; +import com.catchtable.user.entity.User; +import com.catchtable.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReservationReminderNotificationListener { + + private final NotificationService notificationService; + private final UserRepository userRepository; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleReservationReminderEvent(ReservationReminderEvent event) { + User user = userRepository.findById(event.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + String title = "예약 1시간 전 알림"; + String content = String.format("'%s' 매장 %s %s 예약 1시간 전입니다. 늦지 않게 방문해주세요.", + event.getStoreName(), + event.getRemainDate(), + event.getRemainTime()); + + notificationService.createNotification( + user, + NotificationType.RESERVATION_REMINDER, + title, + content, + event.getReservationId() + ); + + log.info("[리마인드 알림] userId: {}, reservationId: {} 알림 생성 완료", + event.getUserId(), event.getReservationId()); + } +} diff --git a/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java b/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java index f92d75e..2363ee2 100644 --- a/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java +++ b/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java @@ -1,15 +1,18 @@ package com.catchtable.reservation.scheduler; -import com.catchtable.notification.service.EmailService; +import com.catchtable.notification.event.ReservationReminderEvent; +import com.catchtable.remain.entity.StoreRemain; import com.catchtable.reservation.entity.Reservation; import com.catchtable.reservation.entity.ReservationStatus; import com.catchtable.reservation.repository.ReservationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -21,36 +24,38 @@ public class ReservationReminderScheduler { private final ReservationRepository reservationRepository; - private final EmailService emailService; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + /** + * 1분 주기로 예약 시각 1시간 전 도달한 예약을 찾아 in-app 리마인드 알림을 발송한다. + * 윈도우: now + 59분 ~ now + 60분 (1분 폭). reminded 플래그로 중복 발송 방지. + */ @Scheduled(fixedRate = 60000) @Transactional public void sendReminder() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(clock); LocalDate today = now.toLocalDate(); - LocalTime from = now.toLocalTime().plusMinutes(20); - LocalTime to = now.toLocalTime().plusMinutes(30); + LocalTime from = now.toLocalTime().plusMinutes(59); + LocalTime to = now.toLocalTime().plusMinutes(60); List targets = reservationRepository.findReminderTargets( ReservationStatus.CONFIRMED, today, from, to); for (Reservation reservation : targets) { - String storeName = reservation.getStoreRemain().getStore().getStoreName(); - String remainDate = reservation.getStoreRemain().getRemainDate().toString(); - String remainTime = reservation.getStoreRemain().getRemainTime().toString(); - String email = reservation.getUser().getEmail(); - - emailService.send(email, - "[캐치테이블] 예약 리마인드", - String.format("%s님, %s %s %s 예약 30분 전입니다.", - reservation.getUser().getNickname(), - storeName, remainDate, remainTime)); - + StoreRemain storeRemain = reservation.getStoreRemain(); + eventPublisher.publishEvent(new ReservationReminderEvent( + reservation.getId(), + reservation.getUser().getId(), + storeRemain.getStore().getStoreName(), + storeRemain.getRemainDate().toString(), + storeRemain.getRemainTime().toString() + )); reservation.markReminded(); } if (!targets.isEmpty()) { - log.info("[리마인드] 총 {}건 이메일 발송 완료", targets.size()); + log.info("[리마인드] 총 {}건 in-app 알림 이벤트 발행", targets.size()); } } } From abb4229430de2d4152fbcf3e18363dc7b77de2eb Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 11 May 2026 18:13:09 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=B0=A9=EB=AC=B8=20=ED=99=95=EC=A0=95=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH /api/v1/reservations/{id}/visit 전용 엔드포인트 신설 - markAsVisited 서비스 메서드 추가 (CONFIRMED → VISITED 전환) - 본인 예약 검증 + CONFIRMED 상태 검증 포함 --- .../controller/ReservationController.java | 11 ++++++++ .../service/ReservationService.java | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/main/java/com/catchtable/reservation/controller/ReservationController.java b/src/main/java/com/catchtable/reservation/controller/ReservationController.java index fe46e8d..26b3baa 100644 --- a/src/main/java/com/catchtable/reservation/controller/ReservationController.java +++ b/src/main/java/com/catchtable/reservation/controller/ReservationController.java @@ -80,4 +80,15 @@ public ResponseEntity> updateReservati .status(SuccessCode.RESERVATION_UPDATE_SUCCESS.getHttpStatus()) .body(ApiResponse.success(SuccessCode.RESERVATION_UPDATE_SUCCESS, responseData)); } + + @PatchMapping("/{reservationId}/visit") + public ResponseEntity> markAsVisited( + @PathVariable Long reservationId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + reservationService.markAsVisited(reservationId, userDetails.getUserId()); + return ResponseEntity + .status(SuccessCode.RESERVATION_UPDATE_SUCCESS.getHttpStatus()) + .body(ApiResponse.success(SuccessCode.RESERVATION_UPDATE_SUCCESS)); + } } diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index 6b2a0ea..cb55511 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -193,6 +193,33 @@ public void updateReservationStatus(Long reservationId, Long userId, Reservation } } + /** + * 사용자가 직접 "방문 확정" 버튼을 눌러 예약을 VISITED 상태로 전환한다. + * CONFIRMED 상태에서만 호출 가능. 호출 후 ReservationVisitedEvent 발행으로 알림이 자동 발송된다. + */ + @Transactional + public void markAsVisited(Long reservationId, Long userId) { + Reservation reservation = reservationRepository.findByIdWithUserAndStoreRemainAndStore(reservationId) + .orElseThrow(() -> new CustomException(ErrorCode.RESERVATION_NOT_FOUND)); + + reservation.validateOwner(userId); + + if (reservation.getStatus() != ReservationStatus.CONFIRMED) { + throw new CustomException(ErrorCode.ALREADY_CANCELED); + } + + reservation.changeStatus(ReservationStatus.VISITED); + + StoreRemain storeRemain = reservation.getStoreRemain(); + eventPublisher.publishEvent(new ReservationVisitedEvent( + reservation.getId(), + reservation.getUser().getId(), + storeRemain.getStore().getStoreName(), + storeRemain.getRemainDate().toString(), + storeRemain.getRemainTime().toString() + )); + } + // ============================================================ // Internal core (알림 발행 X — 호출자가 알림 정책 결정) // ============================================================ From e2e71b21e7585cd4fcb6886b24ca7448847383e7 Mon Sep 17 00:00:00 2001 From: silkair Date: Mon, 11 May 2026 19:21:52 +0900 Subject: [PATCH 06/18] =?UTF-8?q?feat=20:=20=EC=B1=97=EB=B4=87=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20db=20=EC=A0=91=EC=86=8D=20=EA=B5=AC=ED=98=84(?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EC=83=9D=EC=84=B1=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 +- data/init-data.sql | 5 +- .../chatbot/service/ChatbotService.java | 76 ++++++++++++------- .../repository/StoreRemainRepository.java | 12 +++ .../remain/service/StoreRemainService.java | 7 ++ .../service/ReservationService.java | 69 +++++++++++------ 6 files changed, 121 insertions(+), 56 deletions(-) diff --git a/build.gradle b/build.gradle index 44af550..e92d9a4 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,13 @@ java { } } +// spring-ai-bom을 dependencyManagement 블록으로 이동 +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:1.1.4" + } +} + configurations { compileOnly { extendsFrom annotationProcessor @@ -25,7 +32,6 @@ repositories { } dependencies { - implementation platform('org.springframework.ai:spring-ai-bom:1.1.4') implementation 'org.springframework.ai:spring-ai-starter-model-google-genai' implementation 'org.jetbrains.kotlin:kotlin-reflect' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/data/init-data.sql b/data/init-data.sql index 1f225b2..78026d4 100644 --- a/data/init-data.sql +++ b/data/init-data.sql @@ -54,6 +54,8 @@ CREATE TABLE IF NOT EXISTS stores ( is_deleted BOOLEAN NOT NULL ); +DROP TABLE IF EXISTS temp_stores; + CREATE TEMP TABLE temp_stores ( store_name text, store_image text, category text, latitude double precision, longitude double precision, @@ -64,7 +66,7 @@ CREATE TEMP TABLE temp_stores ( COPY temp_stores FROM '/tmp/data/store_data.csv' WITH (FORMAT csv, HEADER true); INSERT INTO stores (store_name, store_image, category, latitude, longitude, address, district, team, open_time, - close_time, status, review_count, bookmark_count, is_deleted, created_at) + close_time, status, review_count, bookmark_count, average_star, is_deleted, created_at) SELECT store_name, NULLIF(store_image, ''), category, @@ -78,6 +80,7 @@ SELECT store_name, 'ACTIVE', 0, 0, + 0.0, false, NOW() FROM temp_stores; diff --git a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java index e287035..00f37ef 100644 --- a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java +++ b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java @@ -1,7 +1,5 @@ package com.catchtable.chatbot.service; -import java.util.List; - import com.catchtable.chatbot.dto.create.ChatMessageRequest; import com.catchtable.chatbot.dto.create.ChatMessageResponse; import com.catchtable.chatbot.dto.read.ChatMessageListResponse; @@ -9,44 +7,53 @@ import com.catchtable.chatbot.entity.MessageRole; import com.catchtable.global.exception.CustomException; import com.catchtable.global.exception.ErrorCode; +import com.catchtable.reservation.service.ReservationService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.prompt.Prompt; import org.springframework.stereotype.Service; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Slf4j @Service @RequiredArgsConstructor public class ChatbotService { private static final int MAX_HISTORY_SIZE = 20; - private static final String SYSTEM_PROMPT = - "너는 캐치테이블 레스토랑 예약 플랫폼의 AI 도우미야. " - + "사용자의 예약, 매장 검색, 맛집 추천 요청을 도와줘. " - + "한국어로 친절하게 답변해."; + private String buildSystemPrompt() { + return "너는 'CatchEat(캐치잇)'이라는 레스토랑 예약 플랫폼의 AI 비서야. " + + "오늘 날짜는 " + java.time.LocalDate.now() + "이야. " + + "사용자가 '5월 12일'처럼 연도 없이 날짜를 말하면 오늘 날짜 기준으로 가장 가까운 미래 날짜로 해석해. " + + "너의 역할은 사용자의 질문을 이해하고, 주어진 도구(함수)를 사용하여 레스토랑 예약 요청을 처리하는 것이야. " + + "사용자가 예약을 요청하면 반드시 'createReservationFromAi' 함수를 호출해서 예약을 처리해야 해. " + + "함수를 호출하기 전에 '매장 이름', '날짜', '시간', '인원수' 4가지 정보가 모두 있는지 확인해. " + + "정보가 부족하면 사용자에게 추가 정보를 요청해. " + + "모든 답변은 한국어로, 친절하고 명확하게 제공해야 해."; + } private final ChatClient chatClient; private final ChatbotDbService dbService; + private final ReservationService reservationService; - // 트랜잭션 없음 — 흐름 제어만 public ChatMessageResponse sendMessage(Long userId, ChatMessageRequest request) { - // 1단계: DB 작업 (@Transactional) — 세션 조회/생성, 일일 제한 확인, 사용자 메시지 저장 Long sessionId = dbService.saveUserMessage(userId, request.message()); - - // 2단계: AI 호출 (트랜잭션 없음 — DB 커넥션 안 잡음) List history = dbService.getRecentHistory(sessionId, MAX_HISTORY_SIZE); - String reply = callAi(history); + + String reply = callAi(history, userId); + if (reply == null || reply.isBlank()) { throw new CustomException(ErrorCode.CHAT_AI_ERROR); } - // 3단계: DB 작업 (@Transactional) — AI 응답 저장 ChatMessage saved = dbService.saveAssistantMessage(sessionId, reply); - return new ChatMessageResponse(saved.getId(), reply); } @@ -54,9 +61,9 @@ public List getMessages(Long userId) { return dbService.getMessages(userId); } - private String callAi(List history) { - List messages = new java.util.ArrayList<>(); - messages.add(new SystemMessage(SYSTEM_PROMPT)); + private String callAi(List history, Long userId) { + List messages = new ArrayList<>(); + messages.add(new SystemMessage(buildSystemPrompt())); for (ChatMessage msg : history) { if (msg.getRole() == MessageRole.USER) { @@ -67,22 +74,33 @@ private String callAi(List history) { } try { - return chatClient.prompt(new Prompt(messages)) + return chatClient.prompt() + .messages(messages) + .tools(reservationService) + .toolContext(Map.of("userId", userId)) .call() .content(); + } catch (Exception e) { - String message = getFullErrorMessage(e).toLowerCase(); - if (message.contains("api key") || message.contains("auth") || message.contains("401")) { - throw new CustomException(ErrorCode.CHAT_AI_AUTH_ERROR); - } - if (message.contains("rate") || message.contains("429") || message.contains("quota")) { - throw new CustomException(ErrorCode.CHAT_AI_RATE_LIMIT); - } - if (message.contains("timeout") || message.contains("timed out")) { - throw new CustomException(ErrorCode.CHAT_AI_TIMEOUT); - } - throw new CustomException(ErrorCode.CHAT_AI_ERROR); + handleAiException(e); + return null; + } + } + + private void handleAiException(Exception e) { + log.error("AI API 호출 중 예외 발생", e); + + String message = getFullErrorMessage(e).toLowerCase(); + if (message.contains("api key") || message.contains("auth") || message.contains("401")) { + throw new CustomException(ErrorCode.CHAT_AI_AUTH_ERROR); + } + if (message.contains("rate") || message.contains("429") || message.contains("quota")) { + throw new CustomException(ErrorCode.CHAT_AI_RATE_LIMIT); + } + if (message.contains("timeout") || message.contains("timed out")) { + throw new CustomException(ErrorCode.CHAT_AI_TIMEOUT); } + throw new CustomException(ErrorCode.CHAT_AI_ERROR); } private String getFullErrorMessage(Exception e) { diff --git a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java index ca8927e..02134e9 100644 --- a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java +++ b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.repository.query.Param; import java.time.LocalDate; +import java.time.LocalTime; import java.util.List; import java.util.Optional; @@ -19,4 +20,15 @@ public interface StoreRemainRepository extends JpaRepository @Query("SELECT sr.remainDate, SUM(sr.remainTeam) FROM StoreRemain sr WHERE sr.store.id = :storeId AND sr.remainDate >= :fromDate GROUP BY sr.remainDate ORDER BY sr.remainDate ASC") List findDateAvailabilityByStoreId(@Param("storeId") Long storeId, @Param("fromDate") LocalDate fromDate); + + @Query("SELECT sr FROM StoreRemain sr " + + "JOIN sr.store s " + + "WHERE s.storeName LIKE %:storeName% " + + "AND sr.remainDate = :date " + + "AND sr.remainTime = :time") + Optional findByStoreNameAndDateTime( + @Param("storeName") String storeName, + @Param("date") LocalDate date, + @Param("time") LocalTime time + ); } diff --git a/src/main/java/com/catchtable/remain/service/StoreRemainService.java b/src/main/java/com/catchtable/remain/service/StoreRemainService.java index 7927830..8e3524f 100644 --- a/src/main/java/com/catchtable/remain/service/StoreRemainService.java +++ b/src/main/java/com/catchtable/remain/service/StoreRemainService.java @@ -19,6 +19,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; +import java.util.Optional; @Slf4j @Service @@ -100,4 +101,10 @@ public List getStoreRemains(Long storeId, LocalDate date )) .toList(); } + + @Transactional(readOnly = true) + public Optional findAvailableRemain(String storeName, LocalDate date, LocalTime time) { + return storeRemainRepository.findByStoreNameAndDateTime(storeName, date, time) + .filter(remain -> remain.getRemainTeam() > 0); // 잔여 팀이 1 이상인 경우만 필터링 + } } diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index 6b2a0ea..4e4daea 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -12,6 +12,7 @@ import com.catchtable.notification.service.VacancyNotificationEmailService; import com.catchtable.remain.entity.StoreRemain; import com.catchtable.remain.repository.StoreRemainRepository; +import com.catchtable.remain.service.StoreRemainService; import com.catchtable.reservation.dto.create.ReservationCreateRequestDto; import com.catchtable.reservation.dto.create.ReservationCreateResponseDto; import com.catchtable.reservation.dto.update.ReservationStatusUpdateRequestDto; @@ -26,13 +27,21 @@ import com.catchtable.user.entity.User; import com.catchtable.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalTime; import java.util.List; +import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor public class ReservationService { @@ -40,20 +49,50 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final UserRepository userRepository; private final StoreRemainRepository storeRemainRepository; + private final StoreRemainService storeRemainService; private final CouponService couponService; private final VacancyNotificationEmailService vacancyNotificationService; private final ApplicationEventPublisher eventPublisher; - // ============================================================ - // Public API - // ============================================================ + @Tool(description = "사용자의 자연어 요청을 기반으로 레스토랑 예약을 생성합니다. " + + "매장 이름은 사용자가 말한 그대로 넘겨주세요. 임의로 변경하지 마세요. " + + "매장 이름, 예약 날짜, 예약 시간, 인원수 정보가 모두 필요합니다. " + + "사용자가 예약을 요청하면 반드시 이 함수를 호출하세요.") + public String createReservationFromAi( + @ToolParam(description = "매장 이름 (예: 모수 서울, 경원집)") String storeName, + @ToolParam(description = "예약 날짜, ISO 형식 (예: 2025-05-11)") LocalDate date, + @ToolParam(description = "예약 시간, HH:mm 형식 (예: 14:00)") LocalTime time, + @ToolParam(description = "예약 인원수 (예: 2)") int member, + ToolContext toolContext + ) { + Long currentUserId = (Long) toolContext.getContext().get("userId"); + + log.info("=== AI Tool 호출 === storeName='{}', date={}, time={}, member={}, userId={}", + storeName, date, time, member, currentUserId); + + Optional availableRemain = + storeRemainService.findAvailableRemain(storeName, date, time); + + log.info("=== 잔여석 조회 결과: {}", + availableRemain.isPresent() ? "있음 (id=" + availableRemain.get().getId() + ")" : "없음"); + + if (availableRemain.isEmpty()) { + return "죄송합니다. 요청하신 시간에 예약 가능한 자리가 없습니다."; + } + + Reservation saved = createReservationCore( + currentUserId, availableRemain.get().getId(), member, null); + return String.format( + "네, %s 레스토랑 %s %s 시간으로 %d명 예약이 완료되었습니다. 예약 번호는 %d번입니다.", + storeName, date, time, member, saved.getId()); + } + @Transactional public ReservationCreateResponseDto create(Long userId, ReservationCreateRequestDto request) { Reservation saved = createReservationCore(userId, request.remainId(), request.member(), request.couponId()); StoreRemain storeRemain = saved.getStoreRemain(); - // 예약 확정 알림 이벤트 발행 eventPublisher.publishEvent(new ReservationConfirmedEvent( saved.getId(), userId, @@ -70,7 +109,6 @@ public void cancelReservation(Long reservationId, Long userId) { Reservation reservation = cancelReservationCore(reservationId, userId, ReservationStatus.CANCELED); StoreRemain storeRemain = reservation.getStoreRemain(); - // 예약 취소 알림 이벤트 발행 eventPublisher.publishEvent(new ReservationCanceledEvent( reservation.getId(), reservation.getUser().getId(), @@ -82,15 +120,12 @@ public void cancelReservation(Long reservationId, Long userId) { @Transactional public ReservationUpdateResponseDto updateReservation(Long reservationId, Long userId, ReservationUpdateRequestDto request) { - // 1. 기존 예약은 변경으로 대체됨 (REPLACED 마킹) — 취소/생성 로직 재사용, 알림은 발행하지 않음 Reservation oldReservation = cancelReservationCore(reservationId, userId, ReservationStatus.REPLACED); StoreRemain oldStoreRemain = oldReservation.getStoreRemain(); - // 2. 새 예약 생성 — 동일하게 알림은 발행하지 않음 Reservation newReservation = createReservationCore(userId, request.newRemainId(), request.newMember(), request.couponId()); StoreRemain newStoreRemain = newReservation.getStoreRemain(); - // 3. 변경 알림 1건만 발행 eventPublisher.publishEvent(new ReservationChangedEvent( newReservation.getId(), userId, @@ -177,11 +212,8 @@ public void updateReservationStatus(Long reservationId, Long userId, Reservation .orElseThrow(() -> new CustomException(ErrorCode.RESERVATION_NOT_FOUND)); reservation.validateOwner(userId); - - // 상태 변경 reservation.changeStatus(request.status()); - // 예약 상태가 VISITED로 변경될 때 이벤트 발행 if (request.status() == ReservationStatus.VISITED) { eventPublisher.publishEvent(new ReservationVisitedEvent( reservation.getId(), @@ -193,14 +225,9 @@ public void updateReservationStatus(Long reservationId, Long userId, Reservation } } - // ============================================================ - // Internal core (알림 발행 X — 호출자가 알림 정책 결정) + // Basic Logic // ============================================================ - /** - * 예약 생성의 핵심 로직: 사용자/시간대 검증, 재고 차감, 쿠폰 적용, 저장. - * 알림은 발행하지 않으므로, 호출자가 상황(생성/변경)에 맞는 이벤트를 직접 publish 해야 한다. - */ private Reservation createReservationCore(Long userId, Long remainId, Integer member, Long couponId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); @@ -231,12 +258,6 @@ private Reservation createReservationCore(Long userId, Long remainId, Integer me return reservationRepository.save(reservation); } - /** - * 예약 종료(취소/대체) 핵심 로직: 상태 마킹, 재고 복구, 빈자리 이벤트, 쿠폰 반환. - * 알림은 발행하지 않으므로, 호출자가 상황(취소/변경)에 맞는 이벤트를 직접 publish 해야 한다. - * - * @param targetStatus CANCELED(진짜 취소) 또는 REPLACED(변경에 의한 대체) - */ private Reservation cancelReservationCore(Long reservationId, Long userId, ReservationStatus targetStatus) { Reservation reservation = getActiveReservation(reservationId, userId); reservation.changeStatus(targetStatus); @@ -249,7 +270,6 @@ private Reservation cancelReservationCore(Long reservationId, Long userId, Reser throw new CustomException(ErrorCode.OPTIMISTIC_LOCK_CONFLICT); } - // 빈자리 발생 이벤트는 취소/변경 양쪽 모두에서 발행 (다른 사용자에게 빈자리 알림) eventPublisher.publishEvent(new VacancyEvent(storeRemain.getId())); if (reservation.getCoupon() != null) { @@ -263,7 +283,6 @@ private Reservation getActiveReservation(Long reservationId, Long userId) { Reservation reservation = reservationRepository.findById(reservationId) .orElseThrow(() -> new CustomException(ErrorCode.RESERVATION_NOT_FOUND)); reservation.validateOwner(userId); - // PENDING/CONFIRMED 상태만 변경/취소 허용. CANCELED/REPLACED/VISITED/NOSHOW 는 모두 종착 상태. ReservationStatus status = reservation.getStatus(); if (status != ReservationStatus.PENDING && status != ReservationStatus.CONFIRMED) { throw new CustomException(ErrorCode.ALREADY_CANCELED); From 3c02248cd7068a061542c18bdae2393ce9a91e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=A7=84?= Date: Mon, 11 May 2026 19:27:44 +0900 Subject: [PATCH 07/18] =?UTF-8?q?=EB=A7=A4=EC=9E=A5=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=20=EC=8B=9C=20=EC=9D=B4=EB=A6=84=20=EC=A0=95=ED=99=95=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EC=9E=85=EB=A0=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../com/catchtable/remain/repository/StoreRemainRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java index 02134e9..9e95850 100644 --- a/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java +++ b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java @@ -23,7 +23,7 @@ public interface StoreRemainRepository extends JpaRepository @Query("SELECT sr FROM StoreRemain sr " + "JOIN sr.store s " + - "WHERE s.storeName LIKE %:storeName% " + + "WHERE s.storeName = :storeName " + "AND sr.remainDate = :date " + "AND sr.remainTime = :time") Optional findByStoreNameAndDateTime( From c18a25e20c39d8cb79e5a22037f5287edb75d803 Mon Sep 17 00:00:00 2001 From: silkair Date: Mon, 11 May 2026 19:41:48 +0900 Subject: [PATCH 08/18] =?UTF-8?q?feat=20:=20=EC=B1=97=EB=B4=87=EC=97=90?= =?UTF-8?q?=EC=95=BD=20=ED=8A=B8=EB=9E=9C=EC=A0=9D=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/service/ReservationService.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index 4e4daea..e6ac1fd 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -58,6 +58,7 @@ public class ReservationService { "매장 이름은 사용자가 말한 그대로 넘겨주세요. 임의로 변경하지 마세요. " + "매장 이름, 예약 날짜, 예약 시간, 인원수 정보가 모두 필요합니다. " + "사용자가 예약을 요청하면 반드시 이 함수를 호출하세요.") + @Transactional public String createReservationFromAi( @ToolParam(description = "매장 이름 (예: 모수 서울, 경원집)") String storeName, @ToolParam(description = "예약 날짜, ISO 형식 (예: 2025-05-11)") LocalDate date, @@ -83,6 +84,14 @@ public String createReservationFromAi( Reservation saved = createReservationCore( currentUserId, availableRemain.get().getId(), member, null); + eventPublisher.publishEvent(new ReservationConfirmedEvent( + saved.getId(), + currentUserId, + saved.getStoreRemain().getStore().getStoreName(), + saved.getStoreRemain().getRemainDate().toString(), + saved.getStoreRemain().getRemainTime().toString() + )); + return String.format( "네, %s 레스토랑 %s %s 시간으로 %d명 예약이 완료되었습니다. 예약 번호는 %d번입니다.", storeName, date, time, member, saved.getId()); From 5d06c21448c81e2a898d27a7c8b3bca19de294ca Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 11 May 2026 19:56:48 +0900 Subject: [PATCH 09/18] =?UTF-8?q?fix:=20=EB=B0=A9=EB=AC=B8=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=B6=80=EC=A0=95?= =?UTF-8?q?=ED=99=95=ED=95=9C=20=EC=97=90=EB=9F=AC=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ALREADY_CANCELED는 VISITED/NOSHOW 상태에도 노출되어 사용자 혼란 유발 할 수 있음. - 방문 확정 전용 NOT_VISITABLE_STATUS 에러 코드 신설 - 제미나이 코드 리뷰 피드백 반영 --- src/main/java/com/catchtable/global/exception/ErrorCode.java | 1 + .../com/catchtable/reservation/service/ReservationService.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/catchtable/global/exception/ErrorCode.java b/src/main/java/com/catchtable/global/exception/ErrorCode.java index f659b7d..0562921 100644 --- a/src/main/java/com/catchtable/global/exception/ErrorCode.java +++ b/src/main/java/com/catchtable/global/exception/ErrorCode.java @@ -62,6 +62,7 @@ public enum ErrorCode implements ResponseCode { RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 예약입니다."), NOT_RESERVATION_OWNER(HttpStatus.FORBIDDEN, "본인의 예약만 접근할 수 있습니다."), ALREADY_CANCELED(HttpStatus.BAD_REQUEST, "이미 취소된 예약입니다."), + NOT_VISITABLE_STATUS(HttpStatus.BAD_REQUEST, "이미 방문 완료 처리되었습니다."), // Remain REMAIN_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 예약 시간대입니다."), diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index cb55511..cc22041 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -205,7 +205,7 @@ public void markAsVisited(Long reservationId, Long userId) { reservation.validateOwner(userId); if (reservation.getStatus() != ReservationStatus.CONFIRMED) { - throw new CustomException(ErrorCode.ALREADY_CANCELED); + throw new CustomException(ErrorCode.NOT_VISITABLE_STATUS); } reservation.changeStatus(ReservationStatus.VISITED); From 0b20279c4d6a87a02dc728760f15a55447980d27 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 11 May 2026 21:05:34 +0900 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=93=9C=20=EC=9C=88=EB=8F=84=EC=9A=B0=20=EC=9E=90=EC=A0=95=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EC=B2=98=EB=A6=AC=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalTime 기반 비교는 자정 넘어가면 wrap around로 윈도우 깨짐 - LocalDateTime 기반 (날짜+시간) 범위 비교로 변경 - findReminderTargets 시그니처: (date, from, to) -> (fromDate, fromTime, toDate, toTime) - 코드 리뷰 피드백 반영 --- .../repository/ReservationRepository.java | 13 ++++++++----- .../scheduler/ReservationReminderScheduler.java | 12 ++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java b/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java index a13caa4..c2ee607 100644 --- a/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java +++ b/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java @@ -19,13 +19,16 @@ public interface ReservationRepository extends JpaRepository @Query("SELECT r FROM Reservation r JOIN FETCH r.storeRemain sr JOIN FETCH r.user " + "WHERE r.status = :status " + "AND r.reminded = false " + - "AND sr.remainDate = :date " + - "AND sr.remainTime BETWEEN :from AND :to") + "AND (sr.remainDate > :fromDate " + + " OR (sr.remainDate = :fromDate AND sr.remainTime >= :fromTime)) " + + "AND (sr.remainDate < :toDate " + + " OR (sr.remainDate = :toDate AND sr.remainTime <= :toTime))") List findReminderTargets( @Param("status") ReservationStatus status, - @Param("date") LocalDate date, - @Param("from") LocalTime from, - @Param("to") LocalTime to); + @Param("fromDate") LocalDate fromDate, + @Param("fromTime") LocalTime fromTime, + @Param("toDate") LocalDate toDate, + @Param("toTime") LocalTime toTime); @Query("SELECT r FROM Reservation r JOIN FETCH r.user u JOIN FETCH r.storeRemain sr JOIN FETCH sr.store s WHERE r.id = :id") Optional findByIdWithUserAndStoreRemainAndStore(@Param("id") Long id); diff --git a/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java b/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java index 2363ee2..f770804 100644 --- a/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java +++ b/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java @@ -13,9 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Clock; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.LocalTime; import java.util.List; @Slf4j @@ -30,17 +28,19 @@ public class ReservationReminderScheduler { /** * 1분 주기로 예약 시각 1시간 전 도달한 예약을 찾아 in-app 리마인드 알림을 발송한다. * 윈도우: now + 59분 ~ now + 60분 (1분 폭). reminded 플래그로 중복 발송 방지. + * 자정 가로지르는 시각도 정확히 처리되도록 LocalDateTime 으로 범위 계산. */ @Scheduled(fixedRate = 60000) @Transactional public void sendReminder() { LocalDateTime now = LocalDateTime.now(clock); - LocalDate today = now.toLocalDate(); - LocalTime from = now.toLocalTime().plusMinutes(59); - LocalTime to = now.toLocalTime().plusMinutes(60); + LocalDateTime from = now.plusMinutes(59); + LocalDateTime to = now.plusMinutes(60); List targets = reservationRepository.findReminderTargets( - ReservationStatus.CONFIRMED, today, from, to); + ReservationStatus.CONFIRMED, + from.toLocalDate(), from.toLocalTime(), + to.toLocalDate(), to.toLocalTime()); for (Reservation reservation : targets) { StoreRemain storeRemain = reservation.getStoreRemain(); From 2213eb4abc1046dc31e2fc1ef566e9c979eceb55 Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 11 May 2026 21:19:39 +0900 Subject: [PATCH 11/18] =?UTF-8?q?refactor:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=BF=BC=EB=A6=AC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=EC=BF=BC=EB=A6=AC=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=ED=91=9C=ED=98=84=EC=8B=9D=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JPQL UPDATE 도 path expression(c.couponTemplate.expiredAt) 사용 가능 - Hibernate가 암시적 조인을 자동 처리하므로 서브쿼리 불필요 - 가독성 향상 (코드 리뷰 피드백 반영) --- .../com/catchtable/coupon/repository/CouponRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/catchtable/coupon/repository/CouponRepository.java b/src/main/java/com/catchtable/coupon/repository/CouponRepository.java index 5f223d5..ce6ff31 100644 --- a/src/main/java/com/catchtable/coupon/repository/CouponRepository.java +++ b/src/main/java/com/catchtable/coupon/repository/CouponRepository.java @@ -18,12 +18,12 @@ public interface CouponRepository extends JpaRepository { /** * UNUSED 상태의 쿠폰 중 템플릿 만료 시각이 지난 것을 일괄 EXPIRED로 전환한다. - * JPQL bulk UPDATE는 JOIN 직접 불가하므로 서브쿼리로 매칭한다. + * 경로 표현식(c.couponTemplate.expiredAt) 사용 시 Hibernate가 암시적 조인을 처리한다. */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE Coupon c SET c.status = com.catchtable.coupon.entity.CouponStatus.EXPIRED " + "WHERE c.status = com.catchtable.coupon.entity.CouponStatus.UNUSED " + "AND c.isDeleted = false " + - "AND c.id IN (SELECT c2.id FROM Coupon c2 WHERE c2.couponTemplate.expiredAt < :now)") + "AND c.couponTemplate.expiredAt < :now") int expireCoupons(@Param("now") LocalDateTime now); } From 0fe65b845f738f9e9a84918a89e34744036a63fa Mon Sep 17 00:00:00 2001 From: johe00123 Date: Mon, 11 May 2026 21:47:03 +0900 Subject: [PATCH 12/18] =?UTF-8?q?refactor:=20NOSHOW=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EB=B2=8C=ED=81=AC=20UPDATE?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 개별 UPDATE N회 -> 단일 벌크 UPDATE 1회로 성능 개선 - updatedAt 도 쿼리에서 직접 갱신 (@PreUpdate 미호출 대응) - 동시성 가드를 WHERE 절로 이동 (DB 레벨 보장) - 코드 리뷰 피드백 반영 --- .../repository/ReservationRepository.java | 24 ++++++++------ .../service/ReservationStatusService.java | 31 +++++-------------- 2 files changed, 22 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java b/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java index c2ee607..11ea2b6 100644 --- a/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java +++ b/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java @@ -1,6 +1,7 @@ package com.catchtable.reservation.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -9,6 +10,7 @@ import com.catchtable.user.entity.User; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; import java.util.List; import java.util.Optional; @@ -34,16 +36,18 @@ List findReminderTargets( Optional findByIdWithUserAndStoreRemainAndStore(@Param("id") Long id); /** - * NOSHOW 자동 전환 대상 조회. - * status = CONFIRMED 이고, 예약 시각(remainDate + remainTime)이 기준 시각(now - 30분) 이전인 건. - * remainDate/remainTime이 분리 저장이라 OR 조건으로 비교한다. + * CONFIRMED 상태이면서 예약 시각이 기준 시각 이전인 예약을 NOSHOW로 일괄 전환한다. + * 벌크 UPDATE라 @PreUpdate가 호출되지 않으므로 updatedAt도 쿼리에서 직접 갱신한다. */ - @Query("SELECT r FROM Reservation r JOIN FETCH r.user u JOIN FETCH r.storeRemain sr JOIN FETCH sr.store s " + - "WHERE r.status = :status " + - "AND (sr.remainDate < :date " + - " OR (sr.remainDate = :date AND sr.remainTime <= :time))") - List findNoshowTargets( - @Param("status") ReservationStatus status, + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Reservation r " + + "SET r.status = com.catchtable.reservation.entity.ReservationStatus.NOSHOW, " + + " r.updatedAt = :now " + + "WHERE r.status = com.catchtable.reservation.entity.ReservationStatus.CONFIRMED " + + "AND (r.storeRemain.remainDate < :date " + + " OR (r.storeRemain.remainDate = :date AND r.storeRemain.remainTime <= :time))") + int bulkTransitionToNoshow( @Param("date") LocalDate date, - @Param("time") LocalTime time); + @Param("time") LocalTime time, + @Param("now") LocalDateTime now); } diff --git a/src/main/java/com/catchtable/reservation/service/ReservationStatusService.java b/src/main/java/com/catchtable/reservation/service/ReservationStatusService.java index ad58c20..2263143 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationStatusService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationStatusService.java @@ -1,17 +1,12 @@ package com.catchtable.reservation.service; -import com.catchtable.reservation.entity.Reservation; -import com.catchtable.reservation.entity.ReservationStatus; import com.catchtable.reservation.repository.ReservationRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Clock; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.List; /** * 예약 상태 자동 전이 비즈니스 로직. @@ -28,29 +23,19 @@ public class ReservationStatusService { private final Clock clock; /** - * 예약 시각이 30분 이상 지났는데 CONFIRMED 상태로 남아있는 예약을 NOSHOW로 전환한다. - * status = CONFIRMED 재확인으로 사용자가 동시에 VISITED 호출하는 경우 race 방지. + * 예약 시각이 30분 이상 지났는데 CONFIRMED 상태로 남아있는 예약을 NOSHOW로 일괄 전환한다. + * 벌크 UPDATE라 DB 왕복 1회로 처리. WHERE status=CONFIRMED 조건이 동시성 가드 역할. * * @return 전환된 건수 */ @Transactional public int transitionExpiredConfirmedToNoshow() { - LocalDateTime threshold = LocalDateTime.now(clock).minusMinutes(NOSHOW_THRESHOLD_MINUTES); - LocalDate date = threshold.toLocalDate(); - LocalTime time = threshold.toLocalTime(); + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime threshold = now.minusMinutes(NOSHOW_THRESHOLD_MINUTES); - List targets = reservationRepository.findNoshowTargets( - ReservationStatus.CONFIRMED, date, time); - - int transitioned = 0; - for (Reservation reservation : targets) { - if (reservation.getStatus() != ReservationStatus.CONFIRMED) { - continue; - } - - reservation.changeStatus(ReservationStatus.NOSHOW); - transitioned++; - } - return transitioned; + return reservationRepository.bulkTransitionToNoshow( + threshold.toLocalDate(), + threshold.toLocalTime(), + now); } } From e281310c544f9713d10625b49e64fc1f8e2cefc6 Mon Sep 17 00:00:00 2001 From: silkair Date: Tue, 12 May 2026 22:05:17 +0900 Subject: [PATCH 13/18] =?UTF-8?q?feat=20:=20=EC=B1=97=EB=B4=87=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EC=BF=A0=ED=8F=B0=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/init-data.sql | 16 ++++++++ .../chatbot/service/ChatbotService.java | 8 +++- .../coupon/service/CouponService.java | 37 +++++++++++++++++++ .../service/ReservationService.java | 13 +++---- 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/data/init-data.sql b/data/init-data.sql index 78026d4..0e1d5c1 100644 --- a/data/init-data.sql +++ b/data/init-data.sql @@ -84,3 +84,19 @@ SELECT store_name, false, NOW() FROM temp_stores; + + +-- 1. 쿠폰 템플릿 생성 (AI 테스트용 5000원 할인 쿠폰) +INSERT INTO coupon_templates (id, coupon_name, amount, discount_rate, started_at, expired_at, remain, created_at, updated_at, is_deleted) +VALUES (100, 'AI 테스트용 쿠폰', 5000, 10, '2026-01-01 00:00:00', '2026-12-31 23:59:59', 100, NOW(), NOW(), false) +ON CONFLICT (id) DO NOTHING; -- 이미 100번 템플릿이 있으면 무시 + +-- 2. 4번 사용자에게 위 템플릿으로 쿠폰 발급 +INSERT INTO coupons (user_id, coupon_template_id, status, created_at, updated_at, is_deleted) +VALUES (4, 100, 'UNUSED', NOW(), NOW(), false) +ON CONFLICT DO NOTHING; -- 이미 해당 유저가 이 쿠폰을 가지고 있으면 무시 + +-- (PostgreSQL 사용 시) 시퀀스 값 업데이트 +SELECT setval('coupon_templates_id_seq', (SELECT MAX(id) FROM coupon_templates)); +SELECT setval('coupons_id_seq', (SELECT MAX(id) FROM coupons)); + diff --git a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java index 00f37ef..d97b978 100644 --- a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java +++ b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java @@ -5,6 +5,7 @@ import com.catchtable.chatbot.dto.read.ChatMessageListResponse; import com.catchtable.chatbot.entity.ChatMessage; import com.catchtable.chatbot.entity.MessageRole; +import com.catchtable.coupon.service.CouponService; import com.catchtable.global.exception.CustomException; import com.catchtable.global.exception.ErrorCode; import com.catchtable.reservation.service.ReservationService; @@ -33,7 +34,9 @@ private String buildSystemPrompt() { + "오늘 날짜는 " + java.time.LocalDate.now() + "이야. " + "사용자가 '5월 12일'처럼 연도 없이 날짜를 말하면 오늘 날짜 기준으로 가장 가까운 미래 날짜로 해석해. " + "너의 역할은 사용자의 질문을 이해하고, 주어진 도구(함수)를 사용하여 레스토랑 예약 요청을 처리하는 것이야. " - + "사용자가 예약을 요청하면 반드시 'createReservationFromAi' 함수를 호출해서 예약을 처리해야 해. " + + "사용자가 예약을 요청하면, 'createReservationFromAi' 함수를 호출하기 전에 반드시 'getAvailableCouponsForAi' 함수를 먼저 호출해서 사용자에게 사용 가능한 쿠폰이 있는지 확인하고, 있다면 어떤 쿠폰을 사용할지 물어봐야 해." + + "만약 사용 가능한 쿠폰이 없다면, 바로 'createReservationFromAi' 함수를 호출해서 예약을 진행해. " + + "사용자가 쿠폰을 사용하겠다고 하면, 'createReservationFromAi' 함수를 호출할 때 'couponId' 파라미터를 포함해서 호출해야 해." + "함수를 호출하기 전에 '매장 이름', '날짜', '시간', '인원수' 4가지 정보가 모두 있는지 확인해. " + "정보가 부족하면 사용자에게 추가 정보를 요청해. " + "모든 답변은 한국어로, 친절하고 명확하게 제공해야 해."; @@ -42,6 +45,7 @@ private String buildSystemPrompt() { private final ChatClient chatClient; private final ChatbotDbService dbService; private final ReservationService reservationService; + private final CouponService couponService; public ChatMessageResponse sendMessage(Long userId, ChatMessageRequest request) { Long sessionId = dbService.saveUserMessage(userId, request.message()); @@ -76,7 +80,7 @@ private String callAi(List history, Long userId) { try { return chatClient.prompt() .messages(messages) - .tools(reservationService) + .tools(reservationService, couponService) .toolContext(Map.of("userId", userId)) .call() .content(); diff --git a/src/main/java/com/catchtable/coupon/service/CouponService.java b/src/main/java/com/catchtable/coupon/service/CouponService.java index fa0e75c..8d57884 100644 --- a/src/main/java/com/catchtable/coupon/service/CouponService.java +++ b/src/main/java/com/catchtable/coupon/service/CouponService.java @@ -6,6 +6,7 @@ import com.catchtable.coupon.dto.read.CouponReadResponse; import com.catchtable.coupon.dto.read.CouponTemplateActiveResponse; import com.catchtable.coupon.entity.Coupon; +import com.catchtable.coupon.entity.CouponStatus; import com.catchtable.coupon.entity.CouponTemplate; import com.catchtable.coupon.repository.CouponRepository; import com.catchtable.coupon.repository.CouponTemplateRepository; @@ -14,12 +15,17 @@ import com.catchtable.user.entity.User; import com.catchtable.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; +import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class CouponService { @@ -96,4 +102,35 @@ public void returnCoupon(Long couponId) { coupon.returnCoupon(); } + + @Tool(description = "사용자가 예약 시 사용할 수 있는 쿠폰 목록을 조회합니다. " + + "쿠폰 ID와 할인 정보를 포함하여 사용자에게 어떤 쿠폰을 사용할지 물어볼 때 사용됩니다.") + @Transactional(readOnly = true) + public String getAvailableCouponsForAi(ToolContext toolContext) { + Long userId = (Long) toolContext.getContext().get("userId"); + log.info("AI Tool 호출: getAvailableCouponsForAi, userId={}", userId); + + List availableCoupons = couponRepository.findAllByUserId(userId).stream() + .filter(coupon -> coupon.getStatus() == CouponStatus.UNUSED && coupon.getCouponTemplate().getExpiredAt().isAfter(LocalDateTime.now())) + .collect(Collectors.toList()); + + if (availableCoupons.isEmpty()) { + return "사용 가능한 쿠폰이 없습니다."; + } + + return availableCoupons.stream() + .map(coupon -> { + String discountInfo; + if (coupon.getCouponTemplate().getDiscountRate() != null) { + discountInfo = coupon.getCouponTemplate().getDiscountRate() + "% 할인"; + } else { + discountInfo = coupon.getCouponTemplate().getAmount() + "원 할인"; + } + return String.format("%s (ID: %d, %s)", + coupon.getCouponTemplate().getCouponName(), + coupon.getId(), + discountInfo); + }) + .collect(Collectors.joining(", ")); + } } diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index 6f266be..47820f4 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -4,10 +4,7 @@ import com.catchtable.coupon.service.CouponService; import com.catchtable.global.exception.CustomException; import com.catchtable.global.exception.ErrorCode; -import com.catchtable.notification.event.ReservationCanceledEvent; -import com.catchtable.notification.event.ReservationChangedEvent; -import com.catchtable.notification.event.ReservationVisitedEvent; -import com.catchtable.notification.event.VacancyEvent; +import com.catchtable.notification.event.*; import com.catchtable.payment.entity.Payment; import com.catchtable.payment.repository.PaymentRepository; import com.catchtable.payment.service.PaymentService; @@ -88,12 +85,13 @@ public String createReservationFromAi( Reservation saved = createReservationCore( currentUserId, availableRemain.get().getId(), member, null); + StoreRemain storeRemain = saved.getStoreRemain(); eventPublisher.publishEvent(new ReservationConfirmedEvent( saved.getId(), currentUserId, - saved.getStoreRemain().getStore().getStoreName(), - saved.getStoreRemain().getRemainDate().toString(), - saved.getStoreRemain().getRemainTime().toString() + storeRemain.getStore().getStoreName(), + storeRemain.getRemainDate().toString(), + storeRemain.getRemainTime().toString() )); return String.format( @@ -105,6 +103,7 @@ public String createReservationFromAi( public ReservationCreateResponseDto create(Long userId, ReservationCreateRequestDto request) { Reservation saved = createReservationCore(userId, request.remainId(), request.member(), request.couponId()); + StoreRemain storeRemain = saved.getStoreRemain(); eventPublisher.publishEvent(new ReservationConfirmedEvent( saved.getId(), userId, From 2a8fbeba2ffa601d2e833fece473b4740cdd16f8 Mon Sep 17 00:00:00 2001 From: kimjb Date: Wed, 13 May 2026 13:42:46 +0900 Subject: [PATCH 14/18] =?UTF-8?q?Refactor:=20=EB=B3=84=EC=A0=90,=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=88=98=20=EA=B3=84=EC=82=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20/=20=EC=98=88=EC=95=BD=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++ .../com/catchtable/CatchtableApplication.java | 2 + .../global/config/SecurityConfig.java | 3 +- .../repository/ReservationRepository.java | 8 +++ .../ReservationCleanupScheduler.java | 50 +++++++++++++++++++ .../service/ReservationService.java | 15 ++++++ .../review/repository/ReviewRepository.java | 4 -- .../review/service/ReviewService.java | 16 +++--- .../com/catchtable/store/entity/Store.java | 28 ++++++++++- .../store/repository/StoreRepository.java | 13 ----- .../store/service/StoreService.java | 25 +++++----- 11 files changed, 127 insertions(+), 40 deletions(-) create mode 100644 src/main/java/com/catchtable/reservation/scheduler/ReservationCleanupScheduler.java diff --git a/.gitignore b/.gitignore index 2da7273..7475426 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,9 @@ PRD.md ### Uploads ### uploads/ +### Python migration scripts (로컬 일회성 작업용) ### +scripts/ + ### Static seed images (로컬에서만 사용, ready_image.png는 예외) ### src/main/resources/static/images/* !src/main/resources/static/images/ready_image.png diff --git a/src/main/java/com/catchtable/CatchtableApplication.java b/src/main/java/com/catchtable/CatchtableApplication.java index 32af6ad..29fbb65 100644 --- a/src/main/java/com/catchtable/CatchtableApplication.java +++ b/src/main/java/com/catchtable/CatchtableApplication.java @@ -5,8 +5,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableAsync +@EnableScheduling @EnableJpaAuditing @SpringBootApplication public class CatchtableApplication { diff --git a/src/main/java/com/catchtable/global/config/SecurityConfig.java b/src/main/java/com/catchtable/global/config/SecurityConfig.java index bc8b7c3..2692d10 100644 --- a/src/main/java/com/catchtable/global/config/SecurityConfig.java +++ b/src/main/java/com/catchtable/global/config/SecurityConfig.java @@ -47,7 +47,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/v3/api-docs/**", "/swagger-resources/**", "/actuator/health", - "/actuator/info" + "/actuator/info", + "/images/**" ).permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/stores/**", diff --git a/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java b/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java index 1cd226f..2d8b446 100644 --- a/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java +++ b/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java @@ -9,6 +9,7 @@ import com.catchtable.user.entity.User; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; import java.util.List; import java.util.Optional; @@ -16,6 +17,13 @@ public interface ReservationRepository extends JpaRepository { List findAllByUser(User user); + // 결제 미완료 PENDING 예약 자동 정리 — 좌석 복원·payment 정리에 storeRemain까지 즉시 로딩 + @Query("SELECT r FROM Reservation r JOIN FETCH r.storeRemain " + + "WHERE r.status = :status AND r.createdAt < :threshold") + List findExpiredPending( + @Param("status") ReservationStatus status, + @Param("threshold") LocalDateTime threshold); + @Query("SELECT r FROM Reservation r JOIN FETCH r.storeRemain sr JOIN FETCH r.user " + "WHERE r.status = :status " + "AND r.reminded = false " + diff --git a/src/main/java/com/catchtable/reservation/scheduler/ReservationCleanupScheduler.java b/src/main/java/com/catchtable/reservation/scheduler/ReservationCleanupScheduler.java new file mode 100644 index 0000000..b3e9a4e --- /dev/null +++ b/src/main/java/com/catchtable/reservation/scheduler/ReservationCleanupScheduler.java @@ -0,0 +1,50 @@ +package com.catchtable.reservation.scheduler; + +import com.catchtable.reservation.entity.Reservation; +import com.catchtable.reservation.entity.ReservationStatus; +import com.catchtable.reservation.repository.ReservationRepository; +import com.catchtable.reservation.service.ReservationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 결제 미완료(PENDING) 예약을 일정 시간 후 자동 정리한다. + * - 좌석 race를 막기 위해 예약 생성 시 좌석을 선점하므로, + * 결제 안 한 사용자가 영구히 좌석을 묶지 않도록 timeout 기반 cleanup이 필요하다. + * - 트랜잭션은 ReservationService.expirePending에서 처리해 self-invocation AOP 문제를 회피. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ReservationCleanupScheduler { + + private static final Duration PENDING_TIMEOUT = Duration.ofMinutes(5); + private static final long FIXED_DELAY_MS = 60_000L; // 1분 + + private final ReservationRepository reservationRepository; + private final ReservationService reservationService; + + @Scheduled(fixedDelay = FIXED_DELAY_MS) + public void cleanupExpiredPending() { + LocalDateTime threshold = LocalDateTime.now().minus(PENDING_TIMEOUT); + List expired = reservationRepository + .findExpiredPending(ReservationStatus.PENDING, threshold); + if (expired.isEmpty()) { + return; + } + log.info("PENDING 예약 cleanup 시작: {}건", expired.size()); + for (Reservation r : expired) { + try { + reservationService.expirePending(r.getId()); + } catch (Exception e) { + log.warn("PENDING cleanup 실패: reservationId={}, reason={}", r.getId(), e.getMessage()); + } + } + } +} diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index d2945eb..c16215f 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -67,6 +67,21 @@ public ReservationCreateResponseDto create(Long userId, ReservationCreateRequest return new ReservationCreateResponseDto(saved.getId(), orderId, DEPOSIT_AMOUNT, saved.getStatus()); } + /** + * 결제 미완료(PENDING) 예약을 PAYMENT_FAILED로 전환 + 좌석 복원 + payment 정리. + * 스케줄러가 timeout 지난 예약을 발견했을 때 호출. + */ + @Transactional + public void expirePending(Long reservationId) { + Reservation reservation = reservationRepository.findById(reservationId).orElse(null); + if (reservation == null || reservation.getStatus() != ReservationStatus.PENDING) { + return; + } + paymentRepository.findByReservation_Id(reservationId).ifPresent(Payment::markFailed); + restoreInventory(reservation); + reservation.changeStatus(ReservationStatus.PAYMENT_FAILED); + } + @Transactional public void cancelReservation(Long reservationId, Long userId) { Reservation reservation = getActiveReservation(reservationId, userId); diff --git a/src/main/java/com/catchtable/review/repository/ReviewRepository.java b/src/main/java/com/catchtable/review/repository/ReviewRepository.java index b8c1877..bc26697 100644 --- a/src/main/java/com/catchtable/review/repository/ReviewRepository.java +++ b/src/main/java/com/catchtable/review/repository/ReviewRepository.java @@ -19,8 +19,4 @@ public interface ReviewRepository extends JpaRepository { // 해당 예약을 통해 이미 리뷰를 작성했는지 확인 (중복 리뷰 방지) boolean existsByReservationIdAndIsDeletedFalse(Long reservationId); - - // 단일 매장의 평균 평점 조회 (리뷰 변경 시 매장 캐시 갱신용) - @Query("SELECT AVG(r.star) FROM Review r WHERE r.store.id = :storeId AND r.isDeleted = false") - Double findAverageStarByStoreId(@Param("storeId") Long storeId); } diff --git a/src/main/java/com/catchtable/review/service/ReviewService.java b/src/main/java/com/catchtable/review/service/ReviewService.java index e6b3ee8..6ddcac5 100644 --- a/src/main/java/com/catchtable/review/service/ReviewService.java +++ b/src/main/java/com/catchtable/review/service/ReviewService.java @@ -66,9 +66,8 @@ public Long createReview(Long userId, ReviewCreateRequestDto request) { reviewRepository.save(review); - // 리뷰 카운트 증가 + 평균 평점 갱신 - storeService.increaseReviewCount(store.getId()); - storeService.recalculateAverageStar(store.getId()); + // 리뷰 카운트 + 평균 평점 갱신 + storeService.applyReviewCreated(store.getId(), request.star()); return review.getId(); } @@ -108,11 +107,12 @@ public Long updateReview(Long userId, Long reviewId, ReviewUpdateRequestDto requ throw new CustomException(ErrorCode.REVIEW_NOT_FOUND); // 이미 삭제된 리뷰 } + Integer oldStar = review.getStar(); review.updateReview(request.star(), request.content(), request.reviewImage()); // 별점이 변경된 경우 매장 평균 평점 갱신 - if (request.star() != null) { - storeService.recalculateAverageStar(review.getStore().getId()); + if (request.star() != null && !request.star().equals(oldStar)) { + storeService.applyReviewUpdated(review.getStore().getId(), oldStar, request.star()); } return review.getId(); } @@ -128,11 +128,11 @@ public void deleteReview(Long userId, Long reviewId) { throw new CustomException(ErrorCode.REVIEW_NOT_FOUND); } + Long storeId = review.getStore().getId(); + int deletedStar = review.getStar(); review.delete(); // 리뷰 카운트 감소 + 평균 평점 갱신 - Long storeId = review.getStore().getId(); - storeService.decreaseReviewCount(storeId); - storeService.recalculateAverageStar(storeId); + storeService.applyReviewDeleted(storeId, deletedStar); } } diff --git a/src/main/java/com/catchtable/store/entity/Store.java b/src/main/java/com/catchtable/store/entity/Store.java index a017a9e..5e4a02e 100644 --- a/src/main/java/com/catchtable/store/entity/Store.java +++ b/src/main/java/com/catchtable/store/entity/Store.java @@ -97,8 +97,32 @@ public void update(String storeName, String storeImage, Category category, this.closeTime = closeTime; } - public void updateAverageStar(Double newAverageStar) { - this.averageStar = newAverageStar != null ? newAverageStar : 0.0; + /** + * 자체 리뷰 생성 시 호출. + * 외부 시드된 average_star, review_count를 base로 두고 평균에 합산. + * 외부 시드가 없는 매장(0.0/0)도 동일 수식으로 정확히 동작. + */ + public void applyReviewCreated(int newStar) { + double total = this.averageStar * this.reviewCount + newStar; + this.reviewCount += 1; + this.averageStar = total / this.reviewCount; + } + + public void applyReviewDeleted(int deletedStar) { + if (this.reviewCount <= 1) { + this.reviewCount = 0; + this.averageStar = 0.0; + return; + } + double total = this.averageStar * this.reviewCount - deletedStar; + this.reviewCount -= 1; + this.averageStar = total / this.reviewCount; + } + + public void applyReviewUpdated(int oldStar, int newStar) { + if (this.reviewCount == 0) return; + double total = this.averageStar * this.reviewCount + (newStar - oldStar); + this.averageStar = total / this.reviewCount; } public void changeStatus(StoreStatus newStatus) { diff --git a/src/main/java/com/catchtable/store/repository/StoreRepository.java b/src/main/java/com/catchtable/store/repository/StoreRepository.java index 765d5bf..874fa62 100644 --- a/src/main/java/com/catchtable/store/repository/StoreRepository.java +++ b/src/main/java/com/catchtable/store/repository/StoreRepository.java @@ -4,7 +4,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -79,16 +78,4 @@ List findInBounds(@Param("minLat") double minLat, @Param("centerLng") double centerLng, Pageable pageable); - /** - * 리뷰 수 증감은 벌크 UPDATE이므로 영속성 컨텍스트와 DB가 일치하도록 - * 호출 전 flush, 호출 후 clear 한다. - * 이로써 같은 트랜잭션 내 후속 findByIdAndIsDeletedFalse() 가 stale 캐시를 반환하지 않는다. - */ - @Modifying(flushAutomatically = true, clearAutomatically = true) - @Query("UPDATE Store s SET s.reviewCount = s.reviewCount + 1 WHERE s.id = :storeId") - void increaseReviewCount(@Param("storeId") Long storeId); - - @Modifying(flushAutomatically = true, clearAutomatically = true) - @Query("UPDATE Store s SET s.reviewCount = s.reviewCount - 1 WHERE s.id = :storeId AND s.reviewCount > 0") - void decreaseReviewCount(@Param("storeId") Long storeId); } diff --git a/src/main/java/com/catchtable/store/service/StoreService.java b/src/main/java/com/catchtable/store/service/StoreService.java index b42eed4..34bd2f6 100644 --- a/src/main/java/com/catchtable/store/service/StoreService.java +++ b/src/main/java/com/catchtable/store/service/StoreService.java @@ -10,7 +10,6 @@ import com.catchtable.store.dto.update.StoreUpdateResponse; import com.catchtable.remain.dto.read.RemainDateResponse; import com.catchtable.remain.repository.StoreRemainRepository; -import com.catchtable.review.repository.ReviewRepository; import com.catchtable.store.entity.Category; import com.catchtable.store.entity.District; import com.catchtable.store.entity.Store; @@ -38,7 +37,6 @@ public class StoreService { private final StoreRepository storeRepository; private final UserRepository userRepository; private final StoreRemainRepository storeRemainRepository; - private final ReviewRepository reviewRepository; // 매장 등록 @Transactional @@ -174,24 +172,27 @@ public StoreStatusUpdateResponse updateStoreStatus(Long userId, Long storeId, St return StoreStatusUpdateResponse.from(store.getId(), store.getStatus().name()); } + /** + * 자체 리뷰 생성 시 호출 — 외부 시드된 별점/리뷰수를 base로 평균에 합산. + */ @Transactional - public void increaseReviewCount(Long storeId) { - storeRepository.increaseReviewCount(storeId); + public void applyReviewCreated(Long storeId, int newStar) { + Store store = storeRepository.findByIdAndIsDeletedFalse(storeId) + .orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND)); + store.applyReviewCreated(newStar); } @Transactional - public void decreaseReviewCount(Long storeId) { - storeRepository.decreaseReviewCount(storeId); + public void applyReviewDeleted(Long storeId, int deletedStar) { + Store store = storeRepository.findByIdAndIsDeletedFalse(storeId) + .orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND)); + store.applyReviewDeleted(deletedStar); } - /** - * 매장 평균 평점 재계산 (리뷰 등록·수정·삭제 시 호출) - */ @Transactional - public void recalculateAverageStar(Long storeId) { + public void applyReviewUpdated(Long storeId, int oldStar, int newStar) { Store store = storeRepository.findByIdAndIsDeletedFalse(storeId) .orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND)); - Double newAverage = reviewRepository.findAverageStarByStoreId(storeId); - store.updateAverageStar(newAverage); + store.applyReviewUpdated(oldStar, newStar); } } From 2b1301eac158d15ba3746989bb0d6da090ccdcc7 Mon Sep 17 00:00:00 2001 From: kimjb Date: Wed, 13 May 2026 14:24:57 +0900 Subject: [PATCH 15/18] =?UTF-8?q?Fix:=20=EC=95=8C=EB=A6=BC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=84=EC=86=A1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/service/ReservationService.java | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index 1019e21..de90e6c 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -6,6 +6,7 @@ import com.catchtable.global.exception.ErrorCode; import com.catchtable.notification.event.ReservationCanceledEvent; import com.catchtable.notification.event.ReservationChangedEvent; +import com.catchtable.notification.event.ReservationConfirmedEvent; import com.catchtable.notification.event.ReservationVisitedEvent; import com.catchtable.notification.event.VacancyEvent; import com.catchtable.payment.entity.Payment; @@ -105,13 +106,7 @@ public String createReservationFromAi( public ReservationCreateResponseDto create(Long userId, ReservationCreateRequestDto request) { Reservation saved = createReservationCore(userId, request.remainId(), request.member(), request.couponId()); - eventPublisher.publishEvent(new ReservationConfirmedEvent( - saved.getId(), - userId, - storeRemain.getStore().getStoreName(), - storeRemain.getRemainDate().toString(), - storeRemain.getRemainTime().toString() - )); + // ConfirmedEvent는 결제 완료 시점(PaymentService.confirmPayment)에서 발행한다. String orderId = "CATCH-" + saved.getId() + "-" + System.currentTimeMillis(); Payment payment = Payment.builder() .reservation(saved) @@ -143,13 +138,6 @@ public void cancelReservation(Long reservationId, Long userId) { Reservation reservation = getActiveReservation(reservationId, userId); StoreRemain storeRemain = reservation.getStoreRemain(); - eventPublisher.publishEvent(new ReservationCanceledEvent( - reservation.getId(), - reservation.getUser().getId(), - storeRemain.getStore().getStoreName(), - storeRemain.getRemainDate().toString(), - storeRemain.getRemainTime().toString() - )); if (reservation.getStatus() == ReservationStatus.PENDING) { // 결제 미완료: PAYMENT_FAILED로 기록 (사용자 예약 취소 내역과 구분) paymentRepository.findByReservation_Id(reservationId).ifPresent(Payment::markFailed); From 15a9f77823449b53d0eec3c9e86cc236789f0764 Mon Sep 17 00:00:00 2001 From: kimjb Date: Wed, 13 May 2026 15:16:37 +0900 Subject: [PATCH 16/18] =?UTF-8?q?Refactor:=20=EB=B3=84=EC=A0=90,=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=88=98=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReservationCleanupScheduler.java | 2 +- .../service/ReservationService.java | 19 ++++-- .../review/event/ReviewCreatedEvent.java | 7 +++ .../review/event/ReviewDeletedEvent.java | 7 +++ .../review/event/ReviewUpdatedEvent.java | 7 +++ .../review/service/ReviewService.java | 20 ++++--- .../listener/StoreReviewStatListener.java | 60 +++++++++++++++++++ .../store/service/StoreService.java | 1 + 8 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/catchtable/review/event/ReviewCreatedEvent.java create mode 100644 src/main/java/com/catchtable/review/event/ReviewDeletedEvent.java create mode 100644 src/main/java/com/catchtable/review/event/ReviewUpdatedEvent.java create mode 100644 src/main/java/com/catchtable/store/listener/StoreReviewStatListener.java diff --git a/src/main/java/com/catchtable/reservation/scheduler/ReservationCleanupScheduler.java b/src/main/java/com/catchtable/reservation/scheduler/ReservationCleanupScheduler.java index b3e9a4e..b646b35 100644 --- a/src/main/java/com/catchtable/reservation/scheduler/ReservationCleanupScheduler.java +++ b/src/main/java/com/catchtable/reservation/scheduler/ReservationCleanupScheduler.java @@ -43,7 +43,7 @@ public void cleanupExpiredPending() { try { reservationService.expirePending(r.getId()); } catch (Exception e) { - log.warn("PENDING cleanup 실패: reservationId={}, reason={}", r.getId(), e.getMessage()); + log.warn("PENDING cleanup 실패: reservationId={}, reason={}", r.getId(), e.getMessage(), e); } } } diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index de90e6c..a349dfc 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -128,9 +128,7 @@ public void expirePending(Long reservationId) { if (reservation == null || reservation.getStatus() != ReservationStatus.PENDING) { return; } - paymentRepository.findByReservation_Id(reservationId).ifPresent(Payment::markFailed); - restoreInventory(reservation); - reservation.changeStatus(ReservationStatus.PAYMENT_FAILED); + handlePendingFailure(reservation); } @Transactional @@ -140,9 +138,7 @@ public void cancelReservation(Long reservationId, Long userId) { if (reservation.getStatus() == ReservationStatus.PENDING) { // 결제 미완료: PAYMENT_FAILED로 기록 (사용자 예약 취소 내역과 구분) - paymentRepository.findByReservation_Id(reservationId).ifPresent(Payment::markFailed); - restoreInventory(reservation); - reservation.changeStatus(ReservationStatus.PAYMENT_FAILED); + handlePendingFailure(reservation); } else { // 결제 완료(CONFIRMED): PortOne 환불 후 CANCELED로 변경 paymentService.refundPayment(reservation); @@ -343,6 +339,17 @@ private Reservation cancelReservationCore(Long reservationId, Long userId, Reser return reservation; } + /** + * 결제 미완료 예약의 공통 정리 로직 (cancelReservation의 PENDING 분기 + expirePending 공용). + * payment를 FAILED로 표시하고, 좌석을 복원하고, 예약 상태를 PAYMENT_FAILED로 전환한다. + */ + private void handlePendingFailure(Reservation reservation) { + paymentRepository.findByReservation_Id(reservation.getId()) + .ifPresent(Payment::markFailed); + restoreInventory(reservation); + reservation.changeStatus(ReservationStatus.PAYMENT_FAILED); + } + private void restoreInventory(Reservation reservation) { StoreRemain storeRemain = reservation.getStoreRemain(); try { diff --git a/src/main/java/com/catchtable/review/event/ReviewCreatedEvent.java b/src/main/java/com/catchtable/review/event/ReviewCreatedEvent.java new file mode 100644 index 0000000..dfcf617 --- /dev/null +++ b/src/main/java/com/catchtable/review/event/ReviewCreatedEvent.java @@ -0,0 +1,7 @@ +package com.catchtable.review.event; + +/** + * 리뷰 생성 후 발행. 매장 평균 별점·리뷰 수 비동기 갱신용. + */ +public record ReviewCreatedEvent(Long storeId, int star) { +} diff --git a/src/main/java/com/catchtable/review/event/ReviewDeletedEvent.java b/src/main/java/com/catchtable/review/event/ReviewDeletedEvent.java new file mode 100644 index 0000000..b5f4710 --- /dev/null +++ b/src/main/java/com/catchtable/review/event/ReviewDeletedEvent.java @@ -0,0 +1,7 @@ +package com.catchtable.review.event; + +/** + * 리뷰 삭제(soft delete) 후 발행. 매장 평균 별점·리뷰 수 비동기 갱신용. + */ +public record ReviewDeletedEvent(Long storeId, int deletedStar) { +} diff --git a/src/main/java/com/catchtable/review/event/ReviewUpdatedEvent.java b/src/main/java/com/catchtable/review/event/ReviewUpdatedEvent.java new file mode 100644 index 0000000..31651ae --- /dev/null +++ b/src/main/java/com/catchtable/review/event/ReviewUpdatedEvent.java @@ -0,0 +1,7 @@ +package com.catchtable.review.event; + +/** + * 리뷰 별점 변경 후 발행. 매장 평균 별점 비동기 재계산용. + */ +public record ReviewUpdatedEvent(Long storeId, int oldStar, int newStar) { +} diff --git a/src/main/java/com/catchtable/review/service/ReviewService.java b/src/main/java/com/catchtable/review/service/ReviewService.java index 6ddcac5..fc06918 100644 --- a/src/main/java/com/catchtable/review/service/ReviewService.java +++ b/src/main/java/com/catchtable/review/service/ReviewService.java @@ -10,13 +10,16 @@ import com.catchtable.review.dto.read.ReviewResponseDto; import com.catchtable.review.dto.update.ReviewUpdateRequestDto; import com.catchtable.review.entity.Review; +import com.catchtable.review.event.ReviewCreatedEvent; +import com.catchtable.review.event.ReviewDeletedEvent; +import com.catchtable.review.event.ReviewUpdatedEvent; import com.catchtable.review.repository.ReviewRepository; import com.catchtable.store.entity.Store; import com.catchtable.store.repository.StoreRepository; -import com.catchtable.store.service.StoreService; import com.catchtable.user.entity.User; import com.catchtable.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,7 +33,7 @@ public class ReviewService { private final ReservationRepository reservationRepository; private final UserRepository userRepository; private final StoreRepository storeRepository; - private final StoreService storeService; + private final ApplicationEventPublisher eventPublisher; @Transactional public Long createReview(Long userId, ReviewCreateRequestDto request) { @@ -66,8 +69,8 @@ public Long createReview(Long userId, ReviewCreateRequestDto request) { reviewRepository.save(review); - // 리뷰 카운트 + 평균 평점 갱신 - storeService.applyReviewCreated(store.getId(), request.star()); + // 리뷰 카운트 + 평균 평점 갱신은 트랜잭션 commit 후 비동기로 처리한다 + eventPublisher.publishEvent(new ReviewCreatedEvent(store.getId(), request.star())); return review.getId(); } @@ -110,9 +113,10 @@ public Long updateReview(Long userId, Long reviewId, ReviewUpdateRequestDto requ Integer oldStar = review.getStar(); review.updateReview(request.star(), request.content(), request.reviewImage()); - // 별점이 변경된 경우 매장 평균 평점 갱신 + // 별점이 변경된 경우 매장 평균 평점 비동기 갱신 if (request.star() != null && !request.star().equals(oldStar)) { - storeService.applyReviewUpdated(review.getStore().getId(), oldStar, request.star()); + eventPublisher.publishEvent(new ReviewUpdatedEvent( + review.getStore().getId(), oldStar, request.star())); } return review.getId(); } @@ -132,7 +136,7 @@ public void deleteReview(Long userId, Long reviewId) { int deletedStar = review.getStar(); review.delete(); - // 리뷰 카운트 감소 + 평균 평점 갱신 - storeService.applyReviewDeleted(storeId, deletedStar); + // 리뷰 카운트 감소 + 평균 평점 비동기 갱신 + eventPublisher.publishEvent(new ReviewDeletedEvent(storeId, deletedStar)); } } diff --git a/src/main/java/com/catchtable/store/listener/StoreReviewStatListener.java b/src/main/java/com/catchtable/store/listener/StoreReviewStatListener.java new file mode 100644 index 0000000..7bb633d --- /dev/null +++ b/src/main/java/com/catchtable/store/listener/StoreReviewStatListener.java @@ -0,0 +1,60 @@ +package com.catchtable.store.listener; + +import com.catchtable.review.event.ReviewCreatedEvent; +import com.catchtable.review.event.ReviewDeletedEvent; +import com.catchtable.review.event.ReviewUpdatedEvent; +import com.catchtable.store.service.StoreService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 리뷰 도메인 이벤트를 구독해 매장의 평균 별점·리뷰 수를 비동기 갱신한다. + * + * - 리뷰 작성·수정·삭제 트랜잭션이 commit된 후에만 동작 (AFTER_COMMIT) + * - @Async로 별도 스레드에서 실행 → 사용자 응답 지연 없음 + * - 갱신 실패가 리뷰 작성 자체를 실패시키지 않는다 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class StoreReviewStatListener { + + private final StoreService storeService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onReviewCreated(ReviewCreatedEvent event) { + try { + storeService.applyReviewCreated(event.storeId(), event.star()); + } catch (Exception e) { + log.warn("매장 별점 갱신 실패(create): storeId={}, star={}", + event.storeId(), event.star(), e); + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onReviewDeleted(ReviewDeletedEvent event) { + try { + storeService.applyReviewDeleted(event.storeId(), event.deletedStar()); + } catch (Exception e) { + log.warn("매장 별점 갱신 실패(delete): storeId={}, deletedStar={}", + event.storeId(), event.deletedStar(), e); + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onReviewUpdated(ReviewUpdatedEvent event) { + try { + storeService.applyReviewUpdated(event.storeId(), event.oldStar(), event.newStar()); + } catch (Exception e) { + log.warn("매장 별점 갱신 실패(update): storeId={}, oldStar={}, newStar={}", + event.storeId(), event.oldStar(), event.newStar(), e); + } + } +} diff --git a/src/main/java/com/catchtable/store/service/StoreService.java b/src/main/java/com/catchtable/store/service/StoreService.java index 34bd2f6..c183e20 100644 --- a/src/main/java/com/catchtable/store/service/StoreService.java +++ b/src/main/java/com/catchtable/store/service/StoreService.java @@ -174,6 +174,7 @@ public StoreStatusUpdateResponse updateStoreStatus(Long userId, Long storeId, St /** * 자체 리뷰 생성 시 호출 — 외부 시드된 별점/리뷰수를 base로 평균에 합산. + * 리스너에서 비동기로 호출되므로 리뷰 작성 자체엔 영향을 주지 않는다. */ @Transactional public void applyReviewCreated(Long storeId, int newStar) { From dca8eb5341256544660e9ec94df746ed41942322 Mon Sep 17 00:00:00 2001 From: silkair Date: Wed, 13 May 2026 16:56:04 +0900 Subject: [PATCH 17/18] =?UTF-8?q?feat=20:=20=EC=B1=97=EB=B4=87=C3=AC?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EC=BF=A0=ED=8F=B0=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../catchtable/chatbot/service/ChatbotService.java | 2 +- .../reservation/service/ReservationService.java | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java index d97b978..418b913 100644 --- a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java +++ b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java @@ -36,7 +36,7 @@ private String buildSystemPrompt() { + "너의 역할은 사용자의 질문을 이해하고, 주어진 도구(함수)를 사용하여 레스토랑 예약 요청을 처리하는 것이야. " + "사용자가 예약을 요청하면, 'createReservationFromAi' 함수를 호출하기 전에 반드시 'getAvailableCouponsForAi' 함수를 먼저 호출해서 사용자에게 사용 가능한 쿠폰이 있는지 확인하고, 있다면 어떤 쿠폰을 사용할지 물어봐야 해." + "만약 사용 가능한 쿠폰이 없다면, 바로 'createReservationFromAi' 함수를 호출해서 예약을 진행해. " - + "사용자가 쿠폰을 사용하겠다고 하면, 'createReservationFromAi' 함수를 호출할 때 'couponId' 파라미터를 포함해서 호출해야 해." + + "사용자가 쿠폰을 사용하겠다고 하면, 답변에서 쿠폰 ID(숫자)를 정확히 추출하여 'createReservationFromAi' 함수의 'couponId' 파라미터에 반드시 포함시켜서 호출해야 해. " + "함수를 호출하기 전에 '매장 이름', '날짜', '시간', '인원수' 4가지 정보가 모두 있는지 확인해. " + "정보가 부족하면 사용자에게 추가 정보를 요청해. " + "모든 답변은 한국어로, 친절하고 명확하게 제공해야 해."; diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index 47820f4..52f9845 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -65,25 +65,24 @@ public String createReservationFromAi( @ToolParam(description = "예약 날짜, ISO 형식 (예: 2025-05-11)") LocalDate date, @ToolParam(description = "예약 시간, HH:mm 형식 (예: 14:00)") LocalTime time, @ToolParam(description = "예약 인원수 (예: 2)") int member, + @ToolParam(description = "사용할 쿠폰의 ID (선택 사항, 없으면 null)") Long couponId, ToolContext toolContext ) { Long currentUserId = (Long) toolContext.getContext().get("userId"); - log.info("=== AI Tool 호출 === storeName='{}', date={}, time={}, member={}, userId={}", - storeName, date, time, member, currentUserId); + log.info("=== AI Tool 호출: createReservationFromAi ===\nuserId: {},\nstoreName: '{}',\ndate: {},\ntime: {},\nmember: {},\ncouponId: {}", + currentUserId, storeName, date, time, member, couponId); Optional availableRemain = storeRemainService.findAvailableRemain(storeName, date, time); - log.info("=== 잔여석 조회 결과: {}", - availableRemain.isPresent() ? "있음 (id=" + availableRemain.get().getId() + ")" : "없음"); - if (availableRemain.isEmpty()) { + log.warn("AI 예약 실패: 사용 가능한 재고 없음. storeName='{}', date={}, time={}", storeName, date, time); return "죄송합니다. 요청하신 시간에 예약 가능한 자리가 없습니다."; } Reservation saved = createReservationCore( - currentUserId, availableRemain.get().getId(), member, null); + currentUserId, availableRemain.get().getId(), member, couponId); StoreRemain storeRemain = saved.getStoreRemain(); eventPublisher.publishEvent(new ReservationConfirmedEvent( @@ -94,6 +93,7 @@ public String createReservationFromAi( storeRemain.getRemainTime().toString() )); + log.info("AI 예약 성공: reservationId={}", saved.getId()); return String.format( "네, %s 레스토랑 %s %s 시간으로 %d명 예약이 완료되었습니다. 예약 번호는 %d번입니다.", storeName, date, time, member, saved.getId()); From e108be488f6b985b0a5a9d1ab31afa312b82b99f Mon Sep 17 00:00:00 2001 From: silkair Date: Wed, 13 May 2026 17:15:14 +0900 Subject: [PATCH 18/18] test --- data/init-data.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/data/init-data.sql b/data/init-data.sql index 0e1d5c1..339d12e 100644 --- a/data/init-data.sql +++ b/data/init-data.sql @@ -100,3 +100,6 @@ ON CONFLICT DO NOTHING; -- 이미 해당 유저가 이 쿠폰을 가지고 있 SELECT setval('coupon_templates_id_seq', (SELECT MAX(id) FROM coupon_templates)); SELECT setval('coupons_id_seq', (SELECT MAX(id) FROM coupons)); + +-- 4번 사용자의 role을 'ADMIN'으로 업데이트 +UPDATE users SET role = 'ADMIN' WHERE id = 4;