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/Dockerfile b/Dockerfile index ec1faf8..74a9ff8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,9 @@ RUN ./gradlew bootJar --no-daemon -x test # ── 2단계: 실행 ──────────────────────────────────────────── FROM eclipse-temurin:21.0.6_7-jre-noble + +ENV TZ=Asia/Seoul + WORKDIR /app # HEALTHCHECK용 wget 설치 (eclipse-temurin jre-noble 이미지에 미포함) diff --git a/build.gradle b/build.gradle index 5845127..8a43163 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-actuator' diff --git a/data/init-data.sql b/data/init-data.sql index 1f225b2..339d12e 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,26 @@ SELECT store_name, 'ACTIVE', 0, 0, + 0.0, 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)); + + +-- 4번 사용자의 role을 'ADMIN'으로 업데이트 +UPDATE users SET role = 'ADMIN' WHERE id = 4; 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/chatbot/service/ChatbotService.java b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java index e287035..418b913 100644 --- a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java +++ b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java @@ -1,52 +1,63 @@ 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; 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; 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' 함수를 호출하기 전에 반드시 'getAvailableCouponsForAi' 함수를 먼저 호출해서 사용자에게 사용 가능한 쿠폰이 있는지 확인하고, 있다면 어떤 쿠폰을 사용할지 물어봐야 해." + + "만약 사용 가능한 쿠폰이 없다면, 바로 'createReservationFromAi' 함수를 호출해서 예약을 진행해. " + + "사용자가 쿠폰을 사용하겠다고 하면, 답변에서 쿠폰 ID(숫자)를 정확히 추출하여 'createReservationFromAi' 함수의 'couponId' 파라미터에 반드시 포함시켜서 호출해야 해. " + + "함수를 호출하기 전에 '매장 이름', '날짜', '시간', '인원수' 4가지 정보가 모두 있는지 확인해. " + + "정보가 부족하면 사용자에게 추가 정보를 요청해. " + + "모든 답변은 한국어로, 친절하고 명확하게 제공해야 해."; + } private final ChatClient chatClient; private final ChatbotDbService dbService; + private final ReservationService reservationService; + private final CouponService couponService; - // 트랜잭션 없음 — 흐름 제어만 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 +65,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 +78,33 @@ private String callAi(List history) { } try { - return chatClient.prompt(new Prompt(messages)) + return chatClient.prompt() + .messages(messages) + .tools(reservationService, couponService) + .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/coupon/repository/CouponRepository.java b/src/main/java/com/catchtable/coupon/repository/CouponRepository.java index 9002fd1..ce6ff31 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로 전환한다. + * 경로 표현식(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.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); + } + } +} 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/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")); + } +} 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/global/exception/ErrorCode.java b/src/main/java/com/catchtable/global/exception/ErrorCode.java index f888689..3bd8a02 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/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/remain/repository/StoreRemainRepository.java b/src/main/java/com/catchtable/remain/repository/StoreRemainRepository.java index ca8927e..9e95850 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 = :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/controller/ReservationController.java b/src/main/java/com/catchtable/reservation/controller/ReservationController.java index 6ca25d8..8241fe9 100644 --- a/src/main/java/com/catchtable/reservation/controller/ReservationController.java +++ b/src/main/java/com/catchtable/reservation/controller/ReservationController.java @@ -143,4 +143,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/repository/ReservationRepository.java b/src/main/java/com/catchtable/reservation/repository/ReservationRepository.java index 1cd226f..a86e72c 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; @@ -16,17 +18,43 @@ 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 " + - "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); + + /** + * CONFIRMED 상태이면서 예약 시각이 기준 시각 이전인 예약을 NOSHOW로 일괄 전환한다. + * 벌크 UPDATE라 @PreUpdate가 호출되지 않으므로 updatedAt도 쿼리에서 직접 갱신한다. + */ + @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("now") LocalDateTime now); } 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..b646b35 --- /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(), e); + } + } + } +} diff --git a/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java b/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java index f92d75e..f770804 100644 --- a/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java +++ b/src/main/java/com/catchtable/reservation/scheduler/ReservationReminderScheduler.java @@ -1,18 +1,19 @@ 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.LocalDate; +import java.time.Clock; import java.time.LocalDateTime; -import java.time.LocalTime; import java.util.List; @Slf4j @@ -21,36 +22,40 @@ 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 플래그로 중복 발송 방지. + * 자정 가로지르는 시각도 정확히 처리되도록 LocalDateTime 으로 범위 계산. + */ @Scheduled(fixedRate = 60000) @Transactional public void sendReminder() { - LocalDateTime now = LocalDateTime.now(); - LocalDate today = now.toLocalDate(); - LocalTime from = now.toLocalTime().plusMinutes(20); - LocalTime to = now.toLocalTime().plusMinutes(30); + LocalDateTime now = LocalDateTime.now(clock); + 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) { - 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()); } } } 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/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index d2945eb..26b24d9 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -6,13 +6,16 @@ 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.notification.event.*; import com.catchtable.payment.entity.Payment; import com.catchtable.payment.repository.PaymentRepository; import com.catchtable.payment.service.PaymentService; 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; @@ -27,13 +30,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 { @@ -43,19 +54,64 @@ 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 ApplicationEventPublisher eventPublisher; private final PaymentRepository paymentRepository; private final PaymentService paymentService; - // ============================================================ - // Public API - // ============================================================ + @Tool(description = "사용자의 자연어 요청을 기반으로 레스토랑 예약을 생성합니다. " + + "매장 이름은 사용자가 말한 그대로 넘겨주세요. 임의로 변경하지 마세요. " + + "매장 이름, 예약 날짜, 예약 시간, 인원수 정보가 모두 필요합니다. " + + "사용자가 예약을 요청하면 반드시 이 함수를 호출하세요.") + @Transactional + 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, + @ToolParam(description = "사용할 쿠폰의 ID (선택 사항, 없으면 null)") Long couponId, + ToolContext toolContext + ) { + Long currentUserId = (Long) toolContext.getContext().get("userId"); + + 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, couponId); + + StoreRemain storeRemain = saved.getStoreRemain(); + eventPublisher.publishEvent(new ReservationConfirmedEvent( + saved.getId(), + currentUserId, + storeRemain.getStore().getStoreName(), + storeRemain.getRemainDate().toString(), + storeRemain.getRemainTime().toString() + )); + + log.info("AI 예약 성공: reservationId={}", saved.getId()); + 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()); + // ConfirmedEvent는 결제 완료 시점(PaymentService.confirmPayment)에서 발행한다. String orderId = "CATCH-" + saved.getId() + "-" + System.currentTimeMillis(); Payment payment = Payment.builder() .reservation(saved) @@ -67,6 +123,19 @@ 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; + } + handlePendingFailure(reservation); + } + @Transactional public void cancelReservation(Long reservationId, Long userId) { Reservation reservation = getActiveReservation(reservationId, userId); @@ -74,9 +143,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); @@ -209,7 +276,34 @@ public void updateReservationStatus(Long reservationId, Long userId, Reservation } } - // ============================================================ + // Basic Logic + /** + * 사용자가 직접 "방문 확정" 버튼을 눌러 예약을 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.NOT_VISITABLE_STATUS); + } + + 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 // ============================================================ @@ -250,6 +344,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 { @@ -258,6 +363,7 @@ private void restoreInventory(Reservation reservation) { } catch (OptimisticLockingFailureException e) { throw new CustomException(ErrorCode.OPTIMISTIC_LOCK_CONFLICT); } + eventPublisher.publishEvent(new VacancyEvent(storeRemain.getId())); if (reservation.getCoupon() != null) { couponService.returnCoupon(reservation.getCoupon().getId()); 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..2263143 --- /dev/null +++ b/src/main/java/com/catchtable/reservation/service/ReservationStatusService.java @@ -0,0 +1,41 @@ +package com.catchtable.reservation.service; + +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.LocalDateTime; + +/** + * 예약 상태 자동 전이 비즈니스 로직. + * 스케줄러는 트리거만 담당하고, 실제 로직은 이 서비스가 가진다. + * 테스트 시 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로 일괄 전환한다. + * 벌크 UPDATE라 DB 왕복 1회로 처리. WHERE status=CONFIRMED 조건이 동시성 가드 역할. + * + * @return 전환된 건수 + */ + @Transactional + public int transitionExpiredConfirmedToNoshow() { + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime threshold = now.minusMinutes(NOSHOW_THRESHOLD_MINUTES); + + return reservationRepository.bulkTransitionToNoshow( + threshold.toLocalDate(), + threshold.toLocalTime(), + now); + } +} 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/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..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,9 +69,8 @@ public Long createReview(Long userId, ReviewCreateRequestDto request) { reviewRepository.save(review); - // 리뷰 카운트 증가 + 평균 평점 갱신 - storeService.increaseReviewCount(store.getId()); - storeService.recalculateAverageStar(store.getId()); + // 리뷰 카운트 + 평균 평점 갱신은 트랜잭션 commit 후 비동기로 처리한다 + eventPublisher.publishEvent(new ReviewCreatedEvent(store.getId(), request.star())); return review.getId(); } @@ -108,11 +110,13 @@ 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)) { + eventPublisher.publishEvent(new ReviewUpdatedEvent( + review.getStore().getId(), oldStar, request.star())); } return review.getId(); } @@ -128,11 +132,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); + // 리뷰 카운트 감소 + 평균 평점 비동기 갱신 + eventPublisher.publishEvent(new ReviewDeletedEvent(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/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/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..c183e20 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,28 @@ 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); } }