diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/aiCourse/service/AICourseService.java b/src/main/java/com/umc/yeogi_gal_lae/api/aiCourse/service/AICourseService.java index a970b67c..14a67362 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/aiCourse/service/AICourseService.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/aiCourse/service/AICourseService.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.umc.yeogi_gal_lae.api.aiCourse.domain.AICourse; import com.umc.yeogi_gal_lae.api.aiCourse.repository.AICourseRepository; +import com.umc.yeogi_gal_lae.api.notification.domain.NotificationType; import com.umc.yeogi_gal_lae.api.place.domain.Place; import com.umc.yeogi_gal_lae.api.place.repository.PlaceRepository; import com.umc.yeogi_gal_lae.api.tripPlan.domain.TripPlan; @@ -21,11 +22,13 @@ import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.client.WebClient; +import com.umc.yeogi_gal_lae.api.notification.service.NotificationService; @Service public class AICourseService { @@ -35,16 +38,21 @@ public class AICourseService { private final PlaceRepository placeRepository; private final WebClient webClient; private final ObjectMapper objectMapper; + private final NotificationService notificationService; @Value("${openai.api.key}") private String apiKey; - public AICourseService(AICourseRepository aiCourseRepository, PlaceRepository placeRepository, - WebClient.Builder webClientBuilder) { + public AICourseService(AICourseRepository aiCourseRepository, + PlaceRepository placeRepository, + WebClient.Builder webClientBuilder, + ObjectMapper objectMapper, + NotificationService notificationService) { this.aiCourseRepository = aiCourseRepository; this.placeRepository = placeRepository; this.webClient = webClientBuilder.baseUrl("https://api.openai.com/v1").build(); - this.objectMapper = new ObjectMapper(); + this.objectMapper = objectMapper; + this.notificationService = notificationService; } /** @@ -55,6 +63,15 @@ public AICourseService(AICourseRepository aiCourseRepository, PlaceRepository pl */ @Transactional public AICourse generateAndStoreAICourse(TripPlan tripPlan) { + // 1. "코스 짜기 시작" 알림 + notificationService.createStartNotification( + tripPlan.getRoom().getName(), // 방 이름 + tripPlan.getUser().getUsername(), // 사용자 이름 + tripPlan.getUser().getEmail(), // 사용자 이메일 + NotificationType.COURSE_START, // 알림 타입 + tripPlan.getId(), + tripPlan.getTripPlanType() + ); // TripPlan에 직접 연결된 Place들을 DB에서 명시적으로 조회 List places = placeRepository.findAllByTripPlanId(tripPlan.getId()); @@ -112,7 +129,16 @@ public AICourse generateAndStoreAICourse(TripPlan tripPlan) { .tripPlan(tripPlan) .courseJson(courseJson) .build(); + // "코스 짜기 완료" 알림 추가 + notificationService.createEndNotification( + tripPlan.getRoom().getName(), // 방 이름 + tripPlan.getUser().getEmail(), // 사용자 이메일 + NotificationType.COURSE_COMPLETE, // 알림 타입 + tripPlan.getId(), + tripPlan.getTripPlanType() + ); return aiCourseRepository.save(aiCourse); + } catch (Exception e) { e.printStackTrace(); return null; diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/budget/service/BudgetService.java b/src/main/java/com/umc/yeogi_gal_lae/api/budget/service/BudgetService.java index 0835f57e..ef6041c2 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/budget/service/BudgetService.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/budget/service/BudgetService.java @@ -14,11 +14,14 @@ import java.util.List; import java.util.Map; import java.util.Optional; + +import com.umc.yeogi_gal_lae.api.notification.domain.NotificationType; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.client.WebClient; +import com.umc.yeogi_gal_lae.api.notification.service.NotificationService; @Service public class BudgetService { @@ -27,19 +30,23 @@ public class BudgetService { private final AICourseRepository aiCourseRepository; private final WebClient webClient; private final ObjectMapper objectMapper; + private final NotificationService notificationService; + @Value("${openai.api.key}") private String apiKey; public BudgetService(BudgetRepository budgetRepository, AICourseRepository aiCourseRepository, - WebClient.Builder webClientBuilder) { + WebClient.Builder webClientBuilder, + ObjectMapper objectMapper, + NotificationService notificationService) { this.budgetRepository = budgetRepository; this.aiCourseRepository = aiCourseRepository; this.webClient = webClientBuilder.baseUrl("https://api.openai.com/v1").build(); - this.objectMapper = new ObjectMapper(); + this.objectMapper = objectMapper; + this.notificationService = notificationService; } - /** * 저장된 AICourse의 스케줄 데이터를 기반으로 GPT API를 호출하여, 각 일차별 각 장소에 대해 예산 추천(예: budgetType 및 추천 금액)을 산출하고, 그 결과를 Budget 엔티티에 * 저장합니다. @@ -54,6 +61,15 @@ public Budget generateAndStoreBudget(Long aiCourseId) { return null; } AICourse aiCourse = aiCourseOpt.get(); + // "예산 정하기 시작" 알림 추가 + notificationService.createStartNotification( + aiCourse.getTripPlan().getRoom().getName(), // 방 이름 + aiCourse.getTripPlan().getUser().getUsername(), // 사용자 이름 + aiCourse.getTripPlan().getUser().getEmail(), // 사용자 이메일 + NotificationType.BUDGET_START, // 알림 타입 (추가 필요) + aiCourse.getTripPlan().getId(), + aiCourse.getTripPlan().getTripPlanType() + ); // aiCourse에 저장된 courseJson은 이미 일정(일차별 장소 이름 목록)을 포함하고 있음 String scheduleJson = aiCourse.getCourseJson(); // 프롬프트 구성: GPT에게 스케줄 정보를 바탕으로 각 장소의 예산 추천을 요청 @@ -62,6 +78,14 @@ public Budget generateAndStoreBudget(Long aiCourseId) { String gptApiResponse = callGptApi(prompt); // 응답 파싱: 예시 결과 JSON은 Map> Map> budgetMap = parseBudgetGptResponse(gptApiResponse); + // "예산 정하기 완료" 알림 추가 + notificationService.createEndNotification( + aiCourse.getTripPlan().getRoom().getName(), // 방 이름 + aiCourse.getTripPlan().getUser().getEmail(), // 사용자 이메일 + NotificationType.BUDGET_COMPLETE, // 알림 타입 (추가 필요) + aiCourse.getTripPlan().getId(), + aiCourse.getTripPlan().getTripPlanType() + ); try { String budgetJson = objectMapper.writeValueAsString(budgetMap); Budget budget = Budget.builder() @@ -69,6 +93,7 @@ public Budget generateAndStoreBudget(Long aiCourseId) { .budgetJson(budgetJson) .build(); return budgetRepository.save(budget); + } catch (JsonProcessingException e) { e.printStackTrace(); return null; diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/notification/controller/NotificationController.java b/src/main/java/com/umc/yeogi_gal_lae/api/notification/controller/NotificationController.java index 8606f450..1dcb925b 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/notification/controller/NotificationController.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/notification/controller/NotificationController.java @@ -59,12 +59,13 @@ public ResponseEntity> createEndNotification( * 최신 알림 조회 API */ @GetMapping - public ResponseEntity>> getAllNotifications() { + public ResponseEntity>> getUserNotifications() { - //로그인 없을시 에러냄 + // 현재 로그인한 사용자의 이메일 가져오기 String userEmail = AuthenticatedUserUtils.getAuthenticatedUserEmail(); - List notifications = notificationService.getAllNotifications(); + List notifications = notificationService.getUserNotifications(userEmail); + return ResponseEntity.ok(Response.of(SuccessCode.NOTIFICATION_FETCH_OK, notifications)); } diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/notification/domain/Notification.java b/src/main/java/com/umc/yeogi_gal_lae/api/notification/domain/Notification.java index e27dbecf..90d97512 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/notification/domain/Notification.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/notification/domain/Notification.java @@ -1,6 +1,7 @@ package com.umc.yeogi_gal_lae.api.notification.domain; import com.umc.yeogi_gal_lae.api.tripPlan.types.TripPlanType; +import com.umc.yeogi_gal_lae.api.user.domain.User; import com.umc.yeogi_gal_lae.global.common.BaseEntity; import jakarta.persistence.*; import lombok.Getter; @@ -16,6 +17,10 @@ public class Notification extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne(fetch = FetchType.LAZY) // 특정 유저의 알림 + @JoinColumn(name = "user_id", nullable = false) + private User user; + private String roomName; // 방 이름 private String userName; // 사용자 이름 (시작 알림에만 필요) private String userEmail; // 사용자 이메일 추가 (중복 문제 해결) diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/notification/repository/NotificationRepository.java b/src/main/java/com/umc/yeogi_gal_lae/api/notification/repository/NotificationRepository.java index c417743a..a658cf8d 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/notification/repository/NotificationRepository.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/notification/repository/NotificationRepository.java @@ -2,17 +2,24 @@ import com.umc.yeogi_gal_lae.api.notification.domain.Notification; import java.util.Optional; + +import com.umc.yeogi_gal_lae.api.user.domain.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.umc.yeogi_gal_lae.api.notification.domain.NotificationType; public interface NotificationRepository extends JpaRepository { - // 최신순 정렬 - List findAllByOrderByCreatedAtDesc(); + // 특정 유저의 모든 알림 조회 (최신순) + List findByUserOrderByCreatedAtDesc(User user); Optional findById(Long id); + // 특정 유저의 알림만 가져오기 + @Query("SELECT n FROM Notification n WHERE n.user.id = :userId ORDER BY n.createdAt DESC") + List findByUserId(@Param("userId") Long userId); + @Query("SELECT COUNT(n) FROM Notification n WHERE n.isRead = false AND n.userEmail = :userEmail") long countUnreadNotificationsByUser(@Param("userEmail") String userEmail); } \ No newline at end of file diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/notification/service/NotificationService.java b/src/main/java/com/umc/yeogi_gal_lae/api/notification/service/NotificationService.java index b845ec00..9828b11d 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/notification/service/NotificationService.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/notification/service/NotificationService.java @@ -5,8 +5,10 @@ import com.umc.yeogi_gal_lae.api.notification.domain.NotificationType; import com.umc.yeogi_gal_lae.api.notification.repository.NotificationRepository; import com.umc.yeogi_gal_lae.api.tripPlan.types.TripPlanType; +import com.umc.yeogi_gal_lae.api.user.domain.User; import com.umc.yeogi_gal_lae.global.error.BusinessException; import com.umc.yeogi_gal_lae.global.error.ErrorCode; +import com.umc.yeogi_gal_lae.api.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -21,6 +23,7 @@ public class NotificationService { private final NotificationRepository notificationRepository; + private final UserRepository userRepository; /** * 시작 알림 생성 (TripPlan 관련 정보 추가) @@ -47,6 +50,8 @@ private void saveNotification(String roomName, String userName, String userEmail notification.setRoomName(roomName); notification.setUserName(userName); notification.setUserEmail(userEmail); // 이메일 저장 + notification.setUser(userRepository.findByEmail(userEmail) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND))); notification.setType(type); notification.setContent(generateCaption(roomName, userName, type, isStart)); notification.setTripPlanId(tripPlanId); @@ -56,24 +61,30 @@ private void saveNotification(String roomName, String userName, String userEmail } /** - * 최신순으로 정렬된 알림 리스트 조회 (TripPlan 정보 포함) + * 특정 유저의 최신 알림 리스트 조회 (타입 필터링 지원) */ @Transactional(readOnly = true) - public List getAllNotifications() { - List notifications = notificationRepository.findAllByOrderByCreatedAtDesc(); - // 레포지토리가 비어있으면 목업 데이터 반환 + public List getUserNotifications(String userEmail) { + // userEmail을 기반으로 User 객체 조회 + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + // 알림 조회 (필터링이 없으면 모든 알림 조회) + List notifications; + notifications = notificationRepository.findByUserOrderByCreatedAtDesc(user); + + // 알림이 없으면 목업 데이터 반환 if (notifications.isEmpty()) { log.warn("알림 데이터 없음, 기본 목업 데이터 반환"); return mockNotifications(); } + return notifications.stream() .map(notification -> new NotificationDto( notification.getId(), notification.getType().getTitle(), generateCaption(notification.getRoomName(), notification.getUserName(), notification.getType(), - notification.getType() == NotificationType.VOTE_START || - notification.getType() == NotificationType.COURSE_START || - notification.getType() == NotificationType.BUDGET_START), + isStartNotification(notification.getType())), notification.getType().name(), notification.getTripPlanId(), notification.getTripPlanType() @@ -82,16 +93,25 @@ public List getAllNotifications() { } /** - * 목업 알림 데이터 생성 + * 목업 데이터 반환 메서드 */ private List mockNotifications() { return List.of( - new NotificationDto(1L, "투표 시작", "mock에서 투표가 시작되었습니다!", "VOTE_START", 100L, TripPlanType.COURSE), - new NotificationDto(2L, "예산 설정 시작", "mock에서 예산 설정이 시작되었습니다!", "BUDGET_START", 101L, TripPlanType.SCHEDULE), - new NotificationDto(3L, "코스 선택 시작", "mock에서 코스 선택이 시작되었습니다!", "COURSE_START", 102L, TripPlanType.SCHEDULE) + new NotificationDto(1L, "투표 시작", "Mock 투표가 시작되었습니다!", "VOTE_START", 100L, TripPlanType.COURSE), + new NotificationDto(2L, "예산 설정 시작", "Mock 예산 설정이 시작되었습니다!", "BUDGET_START", 101L, TripPlanType.SCHEDULE), + new NotificationDto(3L, "코스 선택 시작", "Mock 코스 선택이 시작되었습니다!", "COURSE_START", 102L, TripPlanType.SCHEDULE) ); } + /** + * 특정 알림 타입이 "시작" 알림인지 체크 + */ + private boolean isStartNotification(NotificationType type) { + return type == NotificationType.VOTE_START || + type == NotificationType.COURSE_START || + type == NotificationType.BUDGET_START; + } + /** * 알림에 대한 캡션 생성 (시작 / 종료 통합) */