From 34ca3ac93c3a7b76104ae966415efa1e919e81fb Mon Sep 17 00:00:00 2001 From: dongan Date: Mon, 18 May 2026 15:17:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B1=97=EB=B4=87=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 취소/노쇼 예약 내역 조회 AI Tool 추가 - 주변 인기 맛집 추천 AI Tool 추가 (반경 3km, 인기순 정렬) - 예약 가능 시간대 조회 AI Tool 추가 - 챗봇 요청에 위치 좌표(latitude/longitude) 필드 추가 - 쿠폰 목록 API Cache-Control: no-store 추가 - 챗봇 대화 내역 조회 시 세션 없으면 빈 목록 반환으로 수정 - SecurityConfig ASYNC DispatcherType 설정 제거 Co-Authored-By: Claude Sonnet 4.6 --- .../dto/create/ChatMessageRequest.java | 8 +- .../dto/create/ChatMessageResponse.java | 3 +- .../dto/create/PendingPaymentHolder.java | 17 ++ .../dto/create/PendingPaymentInfo.java | 8 + .../chatbot/service/ChatbotDbService.java | 21 ++- .../chatbot/service/ChatbotService.java | 127 +++++++++++---- .../coupon/controller/CouponController.java | 6 +- .../remain/service/StoreRemainService.java | 34 ++++ .../dto/read/ReservationListResponseDto.java | 3 +- .../service/ReservationService.java | 145 +++++++++++++++--- .../store/repository/StoreRepository.java | 25 +++ .../store/service/StoreService.java | 32 ++++ 12 files changed, 367 insertions(+), 62 deletions(-) create mode 100644 src/main/java/com/catchtable/chatbot/dto/create/PendingPaymentHolder.java create mode 100644 src/main/java/com/catchtable/chatbot/dto/create/PendingPaymentInfo.java diff --git a/src/main/java/com/catchtable/chatbot/dto/create/ChatMessageRequest.java b/src/main/java/com/catchtable/chatbot/dto/create/ChatMessageRequest.java index 8418568..0150acf 100644 --- a/src/main/java/com/catchtable/chatbot/dto/create/ChatMessageRequest.java +++ b/src/main/java/com/catchtable/chatbot/dto/create/ChatMessageRequest.java @@ -9,6 +9,12 @@ public record ChatMessageRequest( @Schema(example = "모수 용산점 3월 22일 18시 3명 예약해줘") @NotBlank(message = "메시지는 필수입니다.") @Size(max = 500, message = "메시지는 500자 이하여야 합니다.") - String message + String message, + + @Schema(description = "사용자 현재 위도 (선택)", example = "37.5665") + Double latitude, + + @Schema(description = "사용자 현재 경도 (선택)", example = "126.9780") + Double longitude ) { } diff --git a/src/main/java/com/catchtable/chatbot/dto/create/ChatMessageResponse.java b/src/main/java/com/catchtable/chatbot/dto/create/ChatMessageResponse.java index a0673f9..3b87f74 100644 --- a/src/main/java/com/catchtable/chatbot/dto/create/ChatMessageResponse.java +++ b/src/main/java/com/catchtable/chatbot/dto/create/ChatMessageResponse.java @@ -2,6 +2,7 @@ public record ChatMessageResponse( Long messageId, - String reply + String reply, + PendingPaymentInfo pendingPayment ) { } diff --git a/src/main/java/com/catchtable/chatbot/dto/create/PendingPaymentHolder.java b/src/main/java/com/catchtable/chatbot/dto/create/PendingPaymentHolder.java new file mode 100644 index 0000000..3564f72 --- /dev/null +++ b/src/main/java/com/catchtable/chatbot/dto/create/PendingPaymentHolder.java @@ -0,0 +1,17 @@ +package com.catchtable.chatbot.dto.create; + +public class PendingPaymentHolder { + private static final ThreadLocal holder = new ThreadLocal<>(); + + public static void set(PendingPaymentInfo info) { + holder.set(info); + } + + public static PendingPaymentInfo get() { + return holder.get(); + } + + public static void clear() { + holder.remove(); + } +} diff --git a/src/main/java/com/catchtable/chatbot/dto/create/PendingPaymentInfo.java b/src/main/java/com/catchtable/chatbot/dto/create/PendingPaymentInfo.java new file mode 100644 index 0000000..3a00ecf --- /dev/null +++ b/src/main/java/com/catchtable/chatbot/dto/create/PendingPaymentInfo.java @@ -0,0 +1,8 @@ +package com.catchtable.chatbot.dto.create; + +public record PendingPaymentInfo( + Long reservationId, + String orderId, + int amount +) { +} diff --git a/src/main/java/com/catchtable/chatbot/service/ChatbotDbService.java b/src/main/java/com/catchtable/chatbot/service/ChatbotDbService.java index 318e916..0b79648 100644 --- a/src/main/java/com/catchtable/chatbot/service/ChatbotDbService.java +++ b/src/main/java/com/catchtable/chatbot/service/ChatbotDbService.java @@ -81,20 +81,19 @@ public List getRecentHistory(Long sessionId, int limit) { return recent.reversed(); } - // 대화 내역 조회 (API 응답용) + // 대화 내역 조회 (API 응답용) — 세션 없으면 빈 목록 반환 @Transactional(readOnly = true) public List getMessages(Long userId) { User user = userRepository.getById(userId); - ChatSession session = chatSessionRepository.findByUserAndIsDeletedFalse(user) - .orElseThrow(() -> new CustomException(ErrorCode.CHAT_SESSION_NOT_FOUND)); - - return chatMessageRepository.findByChatSessionOrderByCreatedAtAsc(session).stream() - .map(message -> new ChatMessageListResponse( - message.getId(), - message.getRole(), - message.getContent(), - message.getCreatedAt())) - .toList(); + return chatSessionRepository.findByUserAndIsDeletedFalse(user) + .map(session -> chatMessageRepository.findByChatSessionOrderByCreatedAtAsc(session).stream() + .map(message -> new ChatMessageListResponse( + message.getId(), + message.getRole(), + message.getContent(), + message.getCreatedAt())) + .toList()) + .orElse(List.of()); } } diff --git a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java index 418b913..bccaf96 100644 --- a/src/main/java/com/catchtable/chatbot/service/ChatbotService.java +++ b/src/main/java/com/catchtable/chatbot/service/ChatbotService.java @@ -2,13 +2,17 @@ import com.catchtable.chatbot.dto.create.ChatMessageRequest; import com.catchtable.chatbot.dto.create.ChatMessageResponse; +import com.catchtable.chatbot.dto.create.PendingPaymentHolder; +import com.catchtable.chatbot.dto.create.PendingPaymentInfo; 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.remain.service.StoreRemainService; import com.catchtable.reservation.service.ReservationService; +import com.catchtable.store.service.StoreService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; @@ -28,46 +32,64 @@ public class ChatbotService { private static final int MAX_HISTORY_SIZE = 20; - - private String buildSystemPrompt() { - return "너는 'CatchEat(캐치잇)'이라는 레스토랑 예약 플랫폼의 AI 비서야. " - + "오늘 날짜는 " + java.time.LocalDate.now() + "이야. " - + "사용자가 '5월 12일'처럼 연도 없이 날짜를 말하면 오늘 날짜 기준으로 가장 가까운 미래 날짜로 해석해. " - + "너의 역할은 사용자의 질문을 이해하고, 주어진 도구(함수)를 사용하여 레스토랑 예약 요청을 처리하는 것이야. " - + "사용자가 예약을 요청하면, 'createReservationFromAi' 함수를 호출하기 전에 반드시 'getAvailableCouponsForAi' 함수를 먼저 호출해서 사용자에게 사용 가능한 쿠폰이 있는지 확인하고, 있다면 어떤 쿠폰을 사용할지 물어봐야 해." - + "만약 사용 가능한 쿠폰이 없다면, 바로 'createReservationFromAi' 함수를 호출해서 예약을 진행해. " - + "사용자가 쿠폰을 사용하겠다고 하면, 답변에서 쿠폰 ID(숫자)를 정확히 추출하여 'createReservationFromAi' 함수의 'couponId' 파라미터에 반드시 포함시켜서 호출해야 해. " - + "함수를 호출하기 전에 '매장 이름', '날짜', '시간', '인원수' 4가지 정보가 모두 있는지 확인해. " - + "정보가 부족하면 사용자에게 추가 정보를 요청해. " - + "모든 답변은 한국어로, 친절하고 명확하게 제공해야 해."; - } + private static final int SUMMARY_KEEP_RECENT = 10; private final ChatClient chatClient; private final ChatbotDbService dbService; private final ReservationService reservationService; private final CouponService couponService; + private final StoreService storeService; + private final StoreRemainService storeRemainService; public ChatMessageResponse sendMessage(Long userId, ChatMessageRequest request) { Long sessionId = dbService.saveUserMessage(userId, request.message()); List history = dbService.getRecentHistory(sessionId, MAX_HISTORY_SIZE); - String reply = callAi(history, userId); + String summarySuffix = buildSummarySuffix(history); + List trimmedHistory = trimHistory(history); + + String reply = callAi(trimmedHistory, userId, request.latitude(), request.longitude(), summarySuffix); + PendingPaymentInfo paymentInfo = PendingPaymentHolder.get(); + PendingPaymentHolder.clear(); if (reply == null || reply.isBlank()) { throw new CustomException(ErrorCode.CHAT_AI_ERROR); } ChatMessage saved = dbService.saveAssistantMessage(sessionId, reply); - return new ChatMessageResponse(saved.getId(), reply); + return new ChatMessageResponse(saved.getId(), reply, paymentInfo); } public List getMessages(Long userId) { return dbService.getMessages(userId); } - private String callAi(List history, Long userId) { + // ============================================================ + // 내부 유틸 + // ============================================================ + + private String callAi(List history, Long userId, Double latitude, Double longitude, String summarySuffix) { + List messages = buildMessages(history, userId, summarySuffix); + try { + Map context = new java.util.HashMap<>(); + context.put("userId", userId); + if (latitude != null) context.put("latitude", latitude); + if (longitude != null) context.put("longitude", longitude); + return chatClient.prompt() + .messages(messages) + .tools(reservationService, couponService, storeService, storeRemainService) + .toolContext(context) + .call() + .content(); + } catch (Exception e) { + handleAiException(e); + return null; + } + } + + private List buildMessages(List history, Long userId, String summarySuffix) { List messages = new ArrayList<>(); - messages.add(new SystemMessage(buildSystemPrompt())); + messages.add(new SystemMessage(buildSystemPrompt(summarySuffix))); for (ChatMessage msg : history) { if (msg.getRole() == MessageRole.USER) { @@ -76,24 +98,75 @@ private String callAi(List history, Long userId) { messages.add(new AssistantMessage(msg.getContent())); } } + return messages; + } + + private String buildSystemPrompt(String summarySuffix) { + return "너는 'CatchEat(캐치잇)'이라는 레스토랑 예약 플랫폼의 AI 비서야. " + + "오늘 날짜는 " + java.time.LocalDate.now() + "이야. " + + "사용자가 '5월 12일'처럼 연도 없이 날짜를 말하면 오늘 날짜 기준으로 가장 가까운 미래 날짜로 해석해. " + + "너의 역할은 사용자의 질문을 이해하고, 주어진 도구(함수)를 사용하여 레스토랑 예약 요청을 처리하는 것이야. " + + // 매장명 확인 로직 + + "사용자가 매장 이름을 말하면, 예약 전에 반드시 'searchStoresByName' 함수로 해당 매장이 존재하는지 확인해. " + + "검색 결과가 없거나 사용자가 말한 이름과 다른 경우, 사용자에게 올바른 매장명을 다시 확인해줘. " + + "매장명이 확인된 경우에만 'createReservationFromAi'를 호출해. " + + // 쿠폰 확인 로직 + + "사용자가 예약을 요청하면, 'createReservationFromAi' 호출 전에 반드시 'getAvailableCouponsForAi' 함수를 먼저 호출해서 " + + "사용 가능한 쿠폰이 있는지 확인하고, 있다면 어떤 쿠폰을 사용할지 물어봐야 해. " + + "만약 사용 가능한 쿠폰이 없다면, 바로 'createReservationFromAi' 함수를 호출해서 예약을 진행해. " + + "사용자가 쿠폰을 사용하겠다고 하면, 쿠폰 ID(숫자)를 추출하여 'createReservationFromAi'의 'couponId' 파라미터에 포함시켜. " + + // 예약 전 필수 정보 확인 + + "함수를 호출하기 전에 '매장 이름', '날짜', '시간', '인원수' 4가지 정보가 모두 있는지 확인해. " + + "정보가 부족하면 사용자에게 추가 정보를 요청해. " + + // 예약 조회/취소 + + "사용자가 '내 예약 보여줘' 또는 '예약 취소해줘'라고 하면 'getMyReservationsForAi' 또는 'cancelReservationFromAi'를 사용해. " + + "사용자가 '취소된 예약', '노쇼 내역', '취소 내역' 등을 요청하면 'getCanceledReservationsForAi'를 사용해. " + + "사용자가 '내 주변 맛집', '근처 맛집', '주변 인기 매장' 등을 요청하면 'getNearbyPopularStoresForAi'를 사용해. " + + "사용자가 특정 매장의 예약 가능한 시간을 물어보면 'getAvailableTimeSlotsForAi'를 사용해. " + + "매장 목록 응답 시 각 매장 이름은 반드시 [매장이름](/stores/{storeId}) 형식의 링크로 작성해. " + + // 결제 안내 금지 + + "예약 완료 후 응답에 결제 안내 섹션, 결제 버튼, 마크다운 표 등을 절대 작성하지 마. " + + "결제 버튼은 UI에서 자동으로 표시되므로, 예약 완료 메시지만 간결하게 전달해. " + + "예시: '경복궁 레스토랑 5월 20일 오전 10시 2명 예약이 완료되었습니다. 보증금 10,000원 결제 후 최종 확정됩니다.' " + + + "모든 답변은 한국어로, 친절하고 명확하게 제공해야 해." + + summarySuffix; + } + + /** 히스토리가 임계치를 넘으면 오래된 부분을 요약 문자열로 반환 */ + private String buildSummarySuffix(List history) { + if (history.size() <= MAX_HISTORY_SIZE) { + return ""; + } + List older = history.subList(0, history.size() - SUMMARY_KEEP_RECENT); + String historyText = older.stream() + .map(m -> (m.getRole() == MessageRole.USER ? "사용자" : "AI") + ": " + m.getContent()) + .reduce("", (a, b) -> a + "\n" + b); try { - return chatClient.prompt() - .messages(messages) - .tools(reservationService, couponService) - .toolContext(Map.of("userId", userId)) + String summary = chatClient.prompt() + .user("다음 대화를 핵심 정보(예약 정보, 사용자 선호 등)만 3문장 이내로 요약해줘:\n" + historyText) .call() .content(); - + return "\n\n[이전 대화 요약]: " + summary; } catch (Exception e) { - handleAiException(e); - return null; + log.warn("대화 요약 실패, 요약 없이 진행", e); + return ""; } } + private List trimHistory(List history) { + if (history.size() <= MAX_HISTORY_SIZE) return history; + return history.subList(history.size() - SUMMARY_KEEP_RECENT, history.size()); + } + 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); @@ -111,9 +184,7 @@ private String getFullErrorMessage(Exception e) { StringBuilder sb = new StringBuilder(); Throwable current = e; while (current != null) { - if (current.getMessage() != null) { - sb.append(current.getMessage()).append(" "); - } + if (current.getMessage() != null) sb.append(current.getMessage()).append(" "); current = current.getCause(); } return sb.toString(); diff --git a/src/main/java/com/catchtable/coupon/controller/CouponController.java b/src/main/java/com/catchtable/coupon/controller/CouponController.java index 2ad6e64..f4dda42 100644 --- a/src/main/java/com/catchtable/coupon/controller/CouponController.java +++ b/src/main/java/com/catchtable/coupon/controller/CouponController.java @@ -11,6 +11,7 @@ import com.catchtable.global.security.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -47,8 +48,9 @@ public ResponseEntity> issueCoupon( @GetMapping("/templates/active") public ResponseEntity>> getActiveTemplates() { List response = couponService.getActiveTemplates(); - return ResponseEntity - .ok(ApiResponse.success(SuccessCode.COUPON_LIST_OK, response)); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .body(ApiResponse.success(SuccessCode.COUPON_LIST_OK, response)); } @GetMapping("/me") diff --git a/src/main/java/com/catchtable/remain/service/StoreRemainService.java b/src/main/java/com/catchtable/remain/service/StoreRemainService.java index 6c65ea4..4579498 100644 --- a/src/main/java/com/catchtable/remain/service/StoreRemainService.java +++ b/src/main/java/com/catchtable/remain/service/StoreRemainService.java @@ -10,6 +10,9 @@ import com.catchtable.store.repository.StoreRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -76,6 +79,37 @@ public void generateMonthlyRemain(StoreRemainCreateRequestDto request) { storeRemainRepository.saveAll(remainsToSave); } + @Tool(description = "특정 매장의 특정 날짜에 예약 가능한 시간대를 조회합니다. '로코페페 5월 20일 예약 가능한 시간', '○○ 매장 언제 예약돼' 등의 요청에 사용하세요.") + @Transactional(readOnly = true) + public String getAvailableTimeSlotsForAi( + @ToolParam(description = "매장 이름 (정확한 이름)") String storeName, + @ToolParam(description = "조회할 날짜, ISO 형식 (예: 2025-05-20)") LocalDate date + ) { + Store store = storeRepository.findAllByIsDeletedFalse().stream() + .filter(s -> s.getStoreName().equalsIgnoreCase(storeName)) + .findFirst() + .orElse(null); + + if (store == null) { + return "'" + storeName + "' 매장을 찾을 수 없습니다. 매장 이름을 다시 확인해주세요."; + } + + List remains = storeRemainRepository.findAllByStoreIdAndDate(store.getId(), date) + .stream() + .filter(r -> r.getRemainTeam() > 0) + .toList(); + + if (remains.isEmpty()) { + return date + "에 " + storeName + " 예약 가능한 시간대가 없습니다."; + } + + String times = remains.stream() + .map(r -> r.getRemainTime().format(DateTimeFormatter.ofPattern("HH:mm"))) + .collect(java.util.stream.Collectors.joining(", ")); + + return date + " " + storeName + " 예약 가능한 시간: " + times; + } + @Transactional(readOnly = true) public List getStoreRemains(Long storeId, LocalDate date) { diff --git a/src/main/java/com/catchtable/reservation/dto/read/ReservationListResponseDto.java b/src/main/java/com/catchtable/reservation/dto/read/ReservationListResponseDto.java index 16734ac..e306c4e 100644 --- a/src/main/java/com/catchtable/reservation/dto/read/ReservationListResponseDto.java +++ b/src/main/java/com/catchtable/reservation/dto/read/ReservationListResponseDto.java @@ -15,6 +15,7 @@ public record ReservationListResponseDto( LocalDate remainDate, LocalTime remainTime, Integer member, - LocalDateTime createdAt + LocalDateTime createdAt, + String orderId ) { } diff --git a/src/main/java/com/catchtable/reservation/service/ReservationService.java b/src/main/java/com/catchtable/reservation/service/ReservationService.java index 26b24d9..e1056ac 100644 --- a/src/main/java/com/catchtable/reservation/service/ReservationService.java +++ b/src/main/java/com/catchtable/reservation/service/ReservationService.java @@ -1,15 +1,15 @@ package com.catchtable.reservation.service; +import com.catchtable.chatbot.dto.create.PendingPaymentHolder; +import com.catchtable.chatbot.dto.create.PendingPaymentInfo; import com.catchtable.coupon.entity.Coupon; 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.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; @@ -27,6 +27,7 @@ import com.catchtable.reservation.entity.ReservationStatus; import com.catchtable.reservation.repository.ReservationRepository; import com.catchtable.store.entity.Store; +import com.catchtable.store.repository.StoreRepository; import com.catchtable.user.entity.User; import com.catchtable.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -36,13 +37,17 @@ import org.springframework.ai.chat.model.ToolContext; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; @Slf4j @Service @@ -59,14 +64,30 @@ public class ReservationService { private final ApplicationEventPublisher eventPublisher; private final PaymentRepository paymentRepository; private final PaymentService paymentService; + private final StoreRepository storeRepository; + + // ============================================================ + // AI Tools + // ============================================================ + + @Tool(description = "매장 이름으로 검색합니다. 사용자가 말한 매장명이 정확하지 않을 수 있으므로, " + + "예약 전에 이 함수로 유사한 매장 이름을 찾아 사용자에게 확인을 받으세요.") + public List searchStoresByName( + @ToolParam(description = "검색할 매장 이름 또는 키워드") String name + ) { + List results = storeRepository.findNamesByNameContaining(name, PageRequest.of(0, 5)); + if (results.isEmpty()) { + return List.of("검색 결과가 없습니다. 다른 키워드로 검색해보세요."); + } + return results; + } @Tool(description = "사용자의 자연어 요청을 기반으로 레스토랑 예약을 생성합니다. " + - "매장 이름은 사용자가 말한 그대로 넘겨주세요. 임의로 변경하지 마세요. " + - "매장 이름, 예약 날짜, 예약 시간, 인원수 정보가 모두 필요합니다. " + - "사용자가 예약을 요청하면 반드시 이 함수를 호출하세요.") + "반드시 searchStoresByName으로 매장명을 확인한 후 호출하세요. " + + "매장 이름, 예약 날짜, 예약 시간, 인원수가 모두 필요합니다.") @Transactional public String createReservationFromAi( - @ToolParam(description = "매장 이름 (예: 모수 서울, 경원집)") String storeName, + @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, @@ -86,26 +107,100 @@ public String createReservationFromAi( if (availableRemain.isEmpty()) { log.warn("AI 예약 실패: 사용 가능한 재고 없음. storeName='{}', date={}, time={}", storeName, date, time); - return "죄송합니다. 요청하신 시간에 예약 가능한 자리가 없습니다."; + return "STORE_OR_SLOT_NOT_FOUND: 요청하신 매장(" + storeName + ")의 " + date + " " + time + " 시간대에 예약 가능한 자리가 없습니다."; } 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() - )); + // Payment 레코드 생성 (결제창 호출을 위해 orderId 필요) + String orderId = "CATCH-" + saved.getId() + "-" + System.currentTimeMillis(); + Payment payment = Payment.builder() + .reservation(saved) + .orderId(orderId) + .amount(DEPOSIT_AMOUNT) + .build(); + paymentRepository.save(payment); + + log.info("AI 예약 성공: reservationId={}, orderId={}", saved.getId(), orderId); + + PendingPaymentHolder.set(new PendingPaymentInfo(saved.getId(), orderId, DEPOSIT_AMOUNT)); - log.info("AI 예약 성공: reservationId={}", saved.getId()); return String.format( - "네, %s 레스토랑 %s %s 시간으로 %d명 예약이 완료되었습니다. 예약 번호는 %d번입니다.", + "네, %s 레스토랑 %s %s 시간으로 %d명 예약이 완료되었습니다. " + + "보증금 10,000원 결제 후 예약이 최종 확정됩니다. 예약 번호는 %d번입니다.", storeName, date, time, member, saved.getId()); } + + @Tool(description = "사용자의 예약 목록을 조회합니다. '내 예약 보여줘', '예약 현황' 등의 요청에 사용하세요.") + @Transactional(readOnly = true) + public String getMyReservationsForAi(ToolContext toolContext) { + Long currentUserId = (Long) toolContext.getContext().get("userId"); + User user = userRepository.findById(currentUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + List reservations = reservationRepository.findAllByUser(user).stream() + .filter(r -> r.getStatus() == ReservationStatus.PENDING || r.getStatus() == ReservationStatus.CONFIRMED) + .toList(); + + if (reservations.isEmpty()) { + return "현재 예정된 예약이 없습니다."; + } + + return reservations.stream().map(r -> { + StoreRemain sr = r.getStoreRemain(); + return String.format("예약번호 %d: %s %s %s, %d명, 상태: %s", + r.getId(), + sr.getStore().getStoreName(), + sr.getRemainDate(), + sr.getRemainTime(), + r.getMember(), + r.getStatus() == ReservationStatus.PENDING ? "결제 대기" : "확정"); + }).collect(Collectors.joining("\n")); + } + + @Tool(description = "취소되거나 노쇼 처리된 예약 내역을 조회합니다. '취소된 예약 보여줘', '노쇼 내역', '취소 내역' 등의 요청에 사용하세요.") + @Transactional(readOnly = true) + public String getCanceledReservationsForAi(ToolContext toolContext) { + Long currentUserId = (Long) toolContext.getContext().get("userId"); + User user = userRepository.findById(currentUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + List reservations = reservationRepository.findAllByUser(user).stream() + .filter(r -> r.getStatus() == ReservationStatus.CANCELED || r.getStatus() == ReservationStatus.NOSHOW) + .toList(); + + if (reservations.isEmpty()) { + return "취소되거나 노쇼 처리된 예약 내역이 없습니다."; + } + + return reservations.stream().map(r -> { + StoreRemain sr = r.getStoreRemain(); + String statusLabel = r.getStatus() == ReservationStatus.CANCELED ? "취소" : "노쇼"; + return String.format("예약번호 %d: %s %s %s, %d명, 상태: %s", + r.getId(), + sr.getStore().getStoreName(), + sr.getRemainDate(), + sr.getRemainTime(), + r.getMember(), + statusLabel); + }).collect(Collectors.joining("\n")); + } + + @Tool(description = "예약을 취소합니다. '예약 취소해줘', '예약번호 X 취소' 등의 요청에 사용하세요.") + @Transactional + public String cancelReservationFromAi( + @ToolParam(description = "취소할 예약 번호 (예약 ID)") Long reservationId, + ToolContext toolContext + ) { + Long currentUserId = (Long) toolContext.getContext().get("userId"); + try { + cancelReservation(reservationId, currentUserId); + return "예약번호 " + reservationId + "번 예약이 취소되었습니다."; + } catch (CustomException e) { + return "예약 취소에 실패했습니다: " + e.getMessage(); + } + } @Transactional public ReservationCreateResponseDto create(Long userId, ReservationCreateRequestDto request) { @@ -201,11 +296,24 @@ public List getUserReservations(Long userId) { List reservations = reservationRepository.findAllByUser(user); + // PENDING 예약에 대해 orderId를 일괄 조회 (결제 진행 버튼용) + List pendingIds = reservations.stream() + .filter(r -> r.getStatus() == ReservationStatus.PENDING) + .map(Reservation::getId) + .toList(); + + Map orderIdByReservationId = new HashMap<>(); + for (Long pendingId : pendingIds) { + paymentRepository.findByReservation_Id(pendingId) + .ifPresent(p -> orderIdByReservationId.put(pendingId, p.getOrderId())); + } + return reservations.stream() .filter(r -> r.getStatus() != ReservationStatus.PAYMENT_FAILED) .map(reservation -> { StoreRemain storeRemain = reservation.getStoreRemain(); Store store = storeRemain.getStore(); + String orderId = orderIdByReservationId.get(reservation.getId()); return new ReservationListResponseDto( reservation.getId(), @@ -218,7 +326,8 @@ public List getUserReservations(Long userId) { storeRemain.getRemainDate(), storeRemain.getRemainTime(), reservation.getMember(), - reservation.getCreatedAt() + reservation.getCreatedAt(), + orderId ); }).toList(); } diff --git a/src/main/java/com/catchtable/store/repository/StoreRepository.java b/src/main/java/com/catchtable/store/repository/StoreRepository.java index 874fa62..cdd8e70 100644 --- a/src/main/java/com/catchtable/store/repository/StoreRepository.java +++ b/src/main/java/com/catchtable/store/repository/StoreRepository.java @@ -58,6 +58,31 @@ List findNearby(@Param("lat") double latitude, * limit를 넘기면 멀리 있는 매장이 잘려나가므로, 분포가 화면 중앙 주변에 균등하게 모인다. * 거리 계산은 PostGIS ST_DistanceSphere 사용. */ + /** + * 반경 내 매장을 인기순(별점→리뷰수→북마크수)으로 정렬. + * radiusMeters 이내 매장만 필터링 후 인기 기준으로 정렬. + */ + @Query(value = """ + SELECT * FROM stores s + WHERE s.is_deleted = false + AND ST_DistanceSphere( + ST_MakePoint(s.longitude, s.latitude), + ST_MakePoint(:lon, :lat) + ) <= :radiusMeters + ORDER BY COALESCE(s.average_star, 0) DESC, + COALESCE(s.review_count, 0) DESC, + COALESCE(s.bookmark_count, 0) DESC, + s.id ASC + """, + nativeQuery = true) + List findNearbyByPopularity(@Param("lat") double latitude, + @Param("lon") double longitude, + @Param("radiusMeters") double radiusMeters, + Pageable pageable); + + @Query("SELECT s.storeName FROM Store s WHERE s.isDeleted = false AND LOWER(s.storeName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY s.storeName ASC") + List findNamesByNameContaining(@Param("name") String name, Pageable pageable); + @Query(value = """ SELECT * FROM stores s WHERE s.is_deleted = false diff --git a/src/main/java/com/catchtable/store/service/StoreService.java b/src/main/java/com/catchtable/store/service/StoreService.java index c183e20..f0b592e 100644 --- a/src/main/java/com/catchtable/store/service/StoreService.java +++ b/src/main/java/com/catchtable/store/service/StoreService.java @@ -19,6 +19,9 @@ import com.catchtable.global.exception.ErrorCode; import jakarta.persistence.criteria.Predicate; import lombok.RequiredArgsConstructor; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -29,6 +32,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -69,6 +73,34 @@ public List getPopularStores(int limit) { .toList(); } + @Tool(description = "사용자 주변의 인기 매장을 조회합니다. '내 주변 맛집', '근처 인기 매장', '주변 맛집 추천' 등의 요청에 사용하세요. 위치 정보가 없으면 사용할 수 없습니다.") + @Transactional(readOnly = true) + public String getNearbyPopularStoresForAi(ToolContext toolContext) { + Object lat = toolContext.getContext().get("latitude"); + Object lon = toolContext.getContext().get("longitude"); + + if (lat == null || lon == null) { + return "위치 정보가 없어 주변 맛집을 조회할 수 없습니다. 위치 권한을 허용한 후 다시 시도해주세요."; + } + + double latitude = ((Number) lat).doubleValue(); + double longitude = ((Number) lon).doubleValue(); + double radiusMeters = 3000; + + List stores = storeRepository.findNearbyByPopularity(latitude, longitude, radiusMeters, PageRequest.of(0, 10)); + + if (stores.isEmpty()) { + return "3km 이내에 등록된 매장이 없습니다."; + } + + return stores.stream() + .map(s -> String.format("[%s](/stores/%d) — 별점 %.1f, 리뷰 %d개", + s.getStoreName(), s.getId(), + s.getAverageStar() != null ? s.getAverageStar() : 0.0, + s.getReviewCount() != null ? s.getReviewCount() : 0)) + .collect(Collectors.joining("\n")); + } + // 내 주변 매장 (좌표 거리 정렬 + DB 페이지네이션) @Transactional(readOnly = true) public List getNearbyStores(double latitude, double longitude, int page, int size) {