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 1ae2d599..a970b67c 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 @@ -6,16 +6,21 @@ 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.place.domain.Place; +import com.umc.yeogi_gal_lae.api.place.repository.PlaceRepository; import com.umc.yeogi_gal_lae.api.tripPlan.domain.TripPlan; import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; @@ -24,17 +29,20 @@ @Service public class AICourseService { + private static final Logger logger = LoggerFactory.getLogger(AICourseService.class); private final AICourseRepository aiCourseRepository; + private final PlaceRepository placeRepository; private final WebClient webClient; private final ObjectMapper objectMapper; @Value("${openai.api.key}") private String apiKey; - public AICourseService(AICourseRepository aiCourseRepository, + public AICourseService(AICourseRepository aiCourseRepository, PlaceRepository placeRepository, WebClient.Builder webClientBuilder) { this.aiCourseRepository = aiCourseRepository; + this.placeRepository = placeRepository; this.webClient = webClientBuilder.baseUrl("https://api.openai.com/v1").build(); this.objectMapper = new ObjectMapper(); } @@ -47,8 +55,17 @@ public AICourseService(AICourseRepository aiCourseRepository, */ @Transactional public AICourse generateAndStoreAICourse(TripPlan tripPlan) { - // TripPlan에 직접 연결된 Place들을 사용 - List places = tripPlan.getPlaces(); // TripPlan에 places 컬렉션이 있다고 가정 + // TripPlan에 직접 연결된 Place들을 DB에서 명시적으로 조회 + List places = placeRepository.findAllByTripPlanId(tripPlan.getId()); + + if (places.isEmpty()) { + logger.warn("TripPlan id {}에 등록된 장소가 없습니다.", tripPlan.getId()); + } else { + List placeNames = places.stream() + .map(Place::getPlaceName) + .collect(Collectors.toList()); + logger.info("TripPlan id {}에 등록된 장소 목록: {}", tripPlan.getId(), placeNames); + } if (places.isEmpty()) { return null; } @@ -60,13 +77,25 @@ public AICourse generateAndStoreAICourse(TripPlan tripPlan) { Map> courseByDay = parseGptResponse(gptApiResponse); // GPT 결과를 실제 Place 객체와 매핑 Map> course = new LinkedHashMap<>(); + Set usedPlaceIds = new HashSet<>(); + for (Map.Entry> entry : courseByDay.entrySet()) { String dayLabel = entry.getKey(); - List dayPlaces = entry.getValue().stream() + // 해당 일차의 추천 장소들을 실제 Place 객체로 매핑 + List recommendedPlaces = entry.getValue().stream() .map(name -> findPlaceByName(places, name)) .filter(Objects::nonNull) .collect(Collectors.toList()); - course.put(dayLabel, dayPlaces); + + // 이미 사용된 장소를 제외하여 유니크한 장소만 할당 (글로벌 중복 제거) + List uniqueForDay = recommendedPlaces.stream() + .filter(place -> !usedPlaceIds.contains(place.getId())) + .collect(Collectors.toList()); + + // 선택된 장소들을 전역 사용 목록에 추가 + uniqueForDay.forEach(place -> usedPlaceIds.add(place.getId())); + + course.put(dayLabel, uniqueForDay); } // 저장할 데이터를 위해 각 일차별 Place 이름 목록으로 변환 Map> courseByName = new LinkedHashMap<>(); @@ -132,7 +161,8 @@ private String buildPrompt(TripPlan tripPlan, List places) { .append("여행 종료일: ").append(tripPlan.getEndDate()).append("\n") .append("총 여행 일수: ").append(totalDays).append("일\n") .append("여행 지역: ").append(tripPlan.getLocation()).append("\n\n") - .append("다음은 방문 가능한 장소 목록 (이름, 주소, 위도, 경도)입니다:\n"); + .append("다음은 해당 여행 계획에 등록된 방문 가능한 장소 목록입니다.\n") + .append("※ 일정 생성 시 반드시 아래 목록에 있는 장소 이름만 사용하며, 동일한 장소가 중복되지 않도록 해 주세요.\n"); for (Place p : places) { promptBuilder.append("- ").append(p.getPlaceName()) .append(" (주소: ").append(p.getAddress()) @@ -143,12 +173,13 @@ private String buildPrompt(TripPlan tripPlan, List places) { .append("위 정보를 바탕으로, 총 ").append(totalDays) .append("일의 여행 일정(각 일차에 방문할 장소 추천)을 생성해줘.\n") .append("일정은 반드시 '1일차', '2일차', ... '").append(totalDays) - .append("일차' 형식의 키를 가지며, 각 키의 값은 해당 일차에 추천할 장소들의 이름 목록이어야 합니다.\n") + .append("일차' 형식의 키를 가지며, 각 키의 값은 위 목록에 있는 장소들의 이름만 포함해야 합니다.\n") .append("예시:\n") .append("{\"1일차\": [\"장소 A\", \"장소 B\"], \"2일차\": [\"장소 C\", \"장소 D\"], ...}"); return promptBuilder.toString(); } + private String callGptApi(String prompt) { Map requestBody = new HashMap<>(); requestBody.put("model", "gpt-4o-mini"); @@ -156,7 +187,7 @@ private String callGptApi(String prompt) { message.put("role", "user"); message.put("content", prompt); requestBody.put("messages", List.of(message)); - return webClient.post() + String response = webClient.post() .uri("/chat/completions") .header("Authorization", "Bearer " + apiKey) .contentType(MediaType.APPLICATION_JSON) @@ -164,6 +195,8 @@ private String callGptApi(String prompt) { .retrieve() .bodyToMono(String.class) .block(); + logger.info("GPT API 응답: {}", response); + return response; } private Map> parseGptResponse(String gptResponse) { @@ -183,8 +216,9 @@ private Map> parseGptResponse(String gptResponse) { } private Place findPlaceByName(List places, String name) { + String normalizedName = name.trim().toLowerCase(); return places.stream() - .filter(p -> p.getPlaceName().equalsIgnoreCase(name)) + .filter(p -> p.getPlaceName().trim().toLowerCase().equals(normalizedName)) .findFirst() .orElse(null); } diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/budget/controller/BudgetController.java b/src/main/java/com/umc/yeogi_gal_lae/api/budget/controller/BudgetController.java index 176bf74f..4bb8c844 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/budget/controller/BudgetController.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/budget/controller/BudgetController.java @@ -4,6 +4,7 @@ import com.umc.yeogi_gal_lae.api.budget.domain.Budget; import com.umc.yeogi_gal_lae.api.budget.dto.AICourseBudgetResponse; import com.umc.yeogi_gal_lae.api.budget.dto.BudgetAssignment; +import com.umc.yeogi_gal_lae.api.budget.dto.BudgetDetailResponse; import com.umc.yeogi_gal_lae.api.budget.dto.BudgetResponse; import com.umc.yeogi_gal_lae.api.budget.dto.DailyBudgetAssignmentResponse; import com.umc.yeogi_gal_lae.api.budget.repository.BudgetRepository; @@ -11,6 +12,7 @@ import com.umc.yeogi_gal_lae.global.common.response.Response; import com.umc.yeogi_gal_lae.global.error.ErrorCode; import com.umc.yeogi_gal_lae.global.success.SuccessCode; +import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.Map; @@ -47,17 +49,32 @@ public Response generateAndStoreBudget(@PathVariable Long aiCour * GET /api/budget/{id} Budget 엔티티의 id로 저장된 예산 추천 데이터를 조회하고, 이를 DailyBudgetAssignmentResponse DTO 리스트로 변환하여 반환합니다. */ @GetMapping("/{budgetId}") - public Response> getBudget(@PathVariable Long budgetId) { + public Response getBudget(@PathVariable Long budgetId) { Optional budgetOpt = budgetService.getBudgetById(budgetId); if (!budgetOpt.isPresent()) { return Response.of(ErrorCode.NOT_FOUND, null); } + Budget budget = budgetOpt.get(); Map> budgetMap = budgetService.getBudgetMapById(budgetId); - List responseList = BudgetConverter.toDailyBudgetAssignmentResponseList( + List dailyAssignments = BudgetConverter.toDailyBudgetAssignmentResponseList( budgetMap); - return Response.of(SuccessCode.OK, responseList); + + // AICourse를 통해 TripPlan 정보를 조회 (TripPlan 클래스가 startDate와 endDate를 LocalDate 타입으로 제공한다고 가정) + String imageUrl = budget.getAiCourse().getTripPlan().getImageUrl(); + LocalDate startDate = budget.getAiCourse().getTripPlan().getStartDate(); + LocalDate endDate = budget.getAiCourse().getTripPlan().getEndDate(); + + BudgetDetailResponse detailResponse = BudgetDetailResponse.builder() + .dailyAssignments(dailyAssignments) + .imageUrl(imageUrl) + .startDate(startDate) + .endDate(endDate) + .build(); + + return Response.of(SuccessCode.OK, detailResponse); } + @GetMapping("/{aiCourseId}/budgetIds") public Response> getBudgetIdsByAiCourseId(@PathVariable Long aiCourseId) { List budgets = diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/budget/domain/BudgetType.java b/src/main/java/com/umc/yeogi_gal_lae/api/budget/domain/BudgetType.java new file mode 100644 index 00000000..2cde79b8 --- /dev/null +++ b/src/main/java/com/umc/yeogi_gal_lae/api/budget/domain/BudgetType.java @@ -0,0 +1,8 @@ +package com.umc.yeogi_gal_lae.api.budget.domain; + +public enum BudgetType { + MEAL, + ACTIVITY, + SHOPPING, + TRANSPORT +} diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/budget/dto/BudgetAssignment.java b/src/main/java/com/umc/yeogi_gal_lae/api/budget/dto/BudgetAssignment.java index bf9c29cd..92518945 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/budget/dto/BudgetAssignment.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/budget/dto/BudgetAssignment.java @@ -1,5 +1,6 @@ package com.umc.yeogi_gal_lae.api.budget.dto; +import com.umc.yeogi_gal_lae.api.budget.domain.BudgetType; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -11,6 +12,6 @@ @Builder public class BudgetAssignment { private String placeName; - private String budgetType; + private BudgetType budgetType; private Double recommendedAmount; } \ No newline at end of file diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/budget/dto/BudgetDetailResponse.java b/src/main/java/com/umc/yeogi_gal_lae/api/budget/dto/BudgetDetailResponse.java new file mode 100644 index 00000000..80c26105 --- /dev/null +++ b/src/main/java/com/umc/yeogi_gal_lae/api/budget/dto/BudgetDetailResponse.java @@ -0,0 +1,20 @@ +package com.umc.yeogi_gal_lae.api.budget.dto; + +import java.time.LocalDate; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BudgetDetailResponse { + private String imageUrl; + private LocalDate startDate; + private LocalDate endDate; + private List dailyAssignments; + +} 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 2e4295fc..5d0a519c 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 @@ -80,20 +80,21 @@ private String buildBudgetPrompt(String scheduleJson) { prompt.append("Given the following travel schedule in JSON format: ") .append(scheduleJson) .append(", generate budget recommendations for each day. "); - prompt.append("For each place, assign exactly one budget type and a recommended amount. "); + prompt.append( + "For each place, assign exactly one budget type (one of MEAL, ACTIVITY, SHOPPING, TRANSPORT) and a recommended amount. "); prompt.append( "Output the result as a JSON object where each key is the day (e.g., '1일차') and each value is an array of objects with the fields: 'placeName', 'budgetType', and 'recommendedAmount'. "); prompt.append("Example output:\n"); prompt.append("{\n"); prompt.append(" \"1일차\": [\n"); prompt.append( - " {\"placeName\": \"장소 예시\", \"budgetType\": \"activityBudget\", \"recommendedAmount\": 20000},\n"); + " {\"placeName\": \"Example Place\", \"budgetType\": \"ACTIVITY\", \"recommendedAmount\": 20000},\n"); prompt.append( - " {\"placeName\": \"음식점 예시\", \"budgetType\": \"mealBudget\", \"recommendedAmount\": 15000}\n"); + " {\"placeName\": \"Example Restaurant\", \"budgetType\": \"MEAL\", \"recommendedAmount\": 15000}\n"); prompt.append(" ],\n"); prompt.append(" \"2일차\": [\n"); prompt.append( - " {\"placeName\": \"장소 예시\", \"budgetType\": \"activityBudget\", \"recommendedAmount\": 25000}\n"); + " {\"placeName\": \"Another Place\", \"budgetType\": \"ACTIVITY\", \"recommendedAmount\": 25000}\n"); prompt.append(" ]\n"); prompt.append("}"); return prompt.toString(); @@ -101,7 +102,7 @@ private String buildBudgetPrompt(String scheduleJson) { private String callGptApi(String prompt) { Map requestBody = new HashMap<>(); - requestBody.put("model", "gpt-4"); + requestBody.put("model", "gpt-4o-mini"); Map message = new HashMap<>(); message.put("role", "user"); message.put("content", prompt); diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/home/converter/HomeConverter.java b/src/main/java/com/umc/yeogi_gal_lae/api/home/converter/HomeConverter.java index 08cf4953..fb978365 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/home/converter/HomeConverter.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/home/converter/HomeConverter.java @@ -15,28 +15,27 @@ public class HomeConverter { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MM-dd"); - public static HomeResponse.OngoingVoteRoom toOngoingVoteRoom(VoteRoom voteRoom) { - - List profileImageUrls = voteRoom.getTripPlan().getRoom().getRoomMembers().stream() - .map(member -> member.getUser().getProfileImage()) - .filter(Objects::nonNull) - .collect(Collectors.toList()); + public static HomeResponse.OngoingVoteRoom toOngoingVoteRoom(TripPlan tripPlan) { + List profileImageUrls = tripPlan.getRoom().getRoomMembers().stream() + .map(member -> member.getUser().getProfileImage()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); return new HomeResponse.OngoingVoteRoom( - voteRoom.getTripPlan().getId(), - voteRoom.getTripPlan().getRoom().getId(), - voteRoom.getTripPlan().getRoom().getMaster().getId(), - voteRoom.getTripPlan().getRoom().getName(), - voteRoom.getTripPlan().getLocation(), - voteRoom.getTripPlan().getRoom().getRoomMembers().size(), - voteRoom.getTripPlan().getVoteLimitTime(), - voteRoom.getTripPlan().getRoom().getRoomMembers().stream().filter(m -> m.getUser().getVote() != null).count(), + tripPlan.getId(), + tripPlan.getRoom().getId(), + tripPlan.getRoom().getMaster().getId(), + tripPlan.getRoom().getName(), + tripPlan.getLocation(), + tripPlan.getRoom().getRoomMembers().size(), + tripPlan.getVoteLimitTime(), + tripPlan.getRoom().getRoomMembers().stream().filter(m -> m.getUser().getVote() != null).count(), profileImageUrls, - voteRoom.getCreatedAt(), - voteRoom.getTripPlan().getTripPlanType(), - voteRoom.getTripPlan().getLatitude(), - voteRoom.getTripPlan().getLongitude() - ); + tripPlan.getCreatedAt(), + tripPlan.getTripPlanType(), + tripPlan.getLatitude(), + tripPlan.getLongitude() + ); } public static HomeResponse.CompletedVoteRoom toCompletedVoteRoom(TripPlan tripPlan, Long aiCourseId) { diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/home/repository/HomeRepository.java b/src/main/java/com/umc/yeogi_gal_lae/api/home/repository/HomeRepository.java index f1aeb7b1..99bb7a26 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/home/repository/HomeRepository.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/home/repository/HomeRepository.java @@ -11,10 +11,9 @@ public interface HomeRepository extends JpaRepository { List findByStatus(Status status); - @Query("SELECT vr FROM VoteRoom vr JOIN vr.tripPlan tp WHERE tp.status = 'ONGOING'") - List findAllOngoingVoteRooms(); + @Query("SELECT tp FROM TripPlan tp WHERE tp.status = 'ONGOING'") + List findAllOngoingTripPlans(); - // 완료된 투표방과 연관된 종료 날짜가 현재 또는 미래인 여행 계획 조회 - @Query("SELECT tp FROM TripPlan tp JOIN tp.voteRoom vr WHERE vr.tripPlan.status = 'COMPLETED' AND tp.endDate >= CURRENT_DATE") - List findCompletedVoteRoomsWithEndDateAfterNow(); + @Query("SELECT tp FROM TripPlan tp WHERE tp.status = 'ONGOING' OR (tp.status = 'COMPLETED' AND tp.tripPlanType = 'COURSE')") + List findAllOngoingAndCompletedCourseTripPlans(); } diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/home/service/HomeService.java b/src/main/java/com/umc/yeogi_gal_lae/api/home/service/HomeService.java index b33e6471..951b34ea 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/home/service/HomeService.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/home/service/HomeService.java @@ -7,10 +7,15 @@ import com.umc.yeogi_gal_lae.api.notification.service.NotificationService; import com.umc.yeogi_gal_lae.api.room.repository.RoomMemberRepository; import com.umc.yeogi_gal_lae.api.tripPlan.domain.TripPlan; +import com.umc.yeogi_gal_lae.api.tripPlan.repository.TripPlanRepository; import com.umc.yeogi_gal_lae.api.tripPlan.types.Status; +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.api.user.repository.UserRepository; import com.umc.yeogi_gal_lae.api.vote.domain.VoteRoom; +import com.umc.yeogi_gal_lae.api.vote.dto.request.VoteRoomRequest; +import com.umc.yeogi_gal_lae.api.vote.repository.VoteRoomRepository; +import com.umc.yeogi_gal_lae.api.vote.service.ValidVoteResultService; import com.umc.yeogi_gal_lae.global.error.BusinessException; import com.umc.yeogi_gal_lae.global.error.ErrorCode; import com.umc.yeogi_gal_lae.global.success.SuccessCode; @@ -21,6 +26,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; @Service @@ -28,8 +34,11 @@ public class HomeService { private final HomeRepository homeRepository; + private final TripPlanRepository tripPlanRepository; private final RoomMemberRepository roomMemberRepository; private final UserRepository userRepository; + private final VoteRoomRepository voteRoomRepository; + private final ValidVoteResultService validVoteResultService; private final NotificationService notificationService; private final AICourseRepository aiCourseRepository; @@ -42,9 +51,12 @@ public Response getOngoingVoteRooms(String use .map(roomMember -> roomMember.getRoom().getId()) .collect(Collectors.toList()); - List rooms = homeRepository.findAllOngoingVoteRooms().stream() - .filter(voteRoom -> userRoomIds.contains(voteRoom.getTripPlan().getRoom().getId())) - .filter(voteRoom -> !isVoteTimeExpired(voteRoom)) // ⬅ **제한 시간이 초과된 투표방 제거** + List rooms = homeRepository.findAllOngoingAndCompletedCourseTripPlans().stream() + .filter(tripPlan -> userRoomIds.contains(tripPlan.getRoom().getId())) + .filter(tripPlan -> + (tripPlan.getTripPlanType() == TripPlanType.COURSE) || + (tripPlan.getTripPlanType() == TripPlanType.SCHEDULE && !isVoteTimeExpired(tripPlan.getVoteRoom())) + ) .map(HomeConverter::toOngoingVoteRoom) .collect(Collectors.toList()); @@ -55,24 +67,54 @@ public Response getOngoingVoteRooms(String use // 완료된 투표방과 연관된 종료 날짜가 현재 또는 미래인 여행 계획 조회 public Response getFutureVoteBasedTrips(String userEmail) { User user = userRepository.findByEmail(userEmail) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - // 사용자가 속한 방 ID 목록 조회 List userRoomIds = roomMemberRepository.findAllByUserId(user.getId()) - .stream() - .map(roomMember -> roomMember.getRoom().getId()) - .collect(Collectors.toList()); - - // 완료된 투표방과 연관된 여행 계획 중 종료 날짜가 현재 또는 미래인 여행 필터링 - List rooms = homeRepository.findCompletedVoteRoomsWithEndDateAfterNow().stream() - .filter(tripPlan -> userRoomIds.contains(tripPlan.getRoom().getId())) - .map(tripPlan -> { - Long aiCourseId = aiCourseRepository.findLatestByTripPlanId(tripPlan.getId()) - .map(aiCourse -> aiCourse.getId()) - .orElse(null); - return HomeConverter.toCompletedVoteRoom(tripPlan, aiCourseId); - }) - .collect(Collectors.toList()); + .stream() + .map(roomMember -> roomMember.getRoom().getId()) + .collect(Collectors.toList()); + + List allOngoingTripPlans = homeRepository.findAllOngoingTripPlans(); + + allOngoingTripPlans.forEach(tripPlan -> { + try { + if (tripPlan.getTripPlanType() == TripPlanType.COURSE && isCourseTimeExpired(tripPlan)) { + tripPlan.setStatus(Status.COMPLETED); + tripPlanRepository.save(tripPlan); + } else if (tripPlan.getTripPlanType() == TripPlanType.SCHEDULE) { + VoteRoom voteRoom = tripPlan.getVoteRoom(); + if (voteRoom != null) { + VoteRoomRequest voteRoomRequest = VoteRoomRequest.builder() + .tripId(tripPlan.getId()) + .roomId(tripPlan.getRoom().getId()) + .voteRoomId(voteRoom.getId()) + .build(); + + boolean isVoteFailed = validVoteResultService.validResult(voteRoomRequest); + + if (isVoteFailed) { + voteRoomRepository.delete(voteRoom); + } + } + } + } catch(BusinessException e){ + // VOTE_NOT_COMPLETED_YET 예외 발생 시, 해당 여행을 건너뛰고 나머지 여행들은 계속 조회 + if (!e.getErrorCode().equals(ErrorCode.VOTE_NOT_COMPLETED_YET)) { + throw e; + } + } + }); + + List rooms = homeRepository.findByStatus(Status.COMPLETED).stream() + .filter(tripPlan -> userRoomIds.contains(tripPlan.getRoom().getId())) + .filter(tripPlan -> !tripPlan.getEndDate().isBefore(LocalDate.now())) + .map(tripPlan -> { + Long aiCourseId = aiCourseRepository.findLatestByTripPlanId(tripPlan.getId()) + .map(aiCourse -> aiCourse.getId()) + .orElse(null); + return HomeConverter.toCompletedVoteRoom(tripPlan, aiCourseId); + }) + .collect(Collectors.toList()); return Response.of(SuccessCode.COMPLETED_VOTE_ROOMS_FETCH_OK, new HomeResponse.CompletedVoteRoomList(rooms.size(), rooms)); } @@ -108,6 +150,16 @@ private boolean isVoteTimeExpired(VoteRoom voteRoom) { return LocalDateTime.now().isAfter(voteEndTime); } + private boolean isCourseTimeExpired(TripPlan tripPlan) { + if (tripPlan.getTripPlanType() != TripPlanType.COURSE || tripPlan.getVoteLimitTime() == null) { + return false; + } + + return LocalDateTime.now().isAfter( + tripPlan.getCreatedAt().plusSeconds(tripPlan.getVoteLimitTime().getSeconds()) + ); + } + /** * 특정 사용자의 읽지 않은 알림 여부 반환 (이메일 기반) */ diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/room/service/RoomService.java b/src/main/java/com/umc/yeogi_gal_lae/api/room/service/RoomService.java index 2879ffb4..62c78e89 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/room/service/RoomService.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/room/service/RoomService.java @@ -167,6 +167,7 @@ public RoomListResponse getRoomsByUserId(Long userId) { .roomId(room.getId()) .roomName(room.getName()) .members(room.getRoomMembers().stream() + .filter(member -> !member.getUser().getId().equals(userId)) // 본인 제외 .map(member -> new SimpleRoomMemberResponse( member.getUser().getId(), member.getUser().getProfileImage() // 프로필 이미지 추가 diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/converter/TripPlanConverter.java b/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/converter/TripPlanConverter.java index 65ec2419..e2b551e9 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/converter/TripPlanConverter.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/converter/TripPlanConverter.java @@ -51,6 +51,7 @@ public static TripPlanResponse toResponse(TripPlan tripPlan) { .id(tripPlan.getId()) .roomId(tripPlan.getRoom().getId()) .masterId(tripPlan.getRoom().getMaster().getId()) + .voteRoomId(tripPlan.getVoteRoom() != null ? tripPlan.getVoteRoom().getId() : null) .location(tripPlan.getLocation()) .startDate(tripPlan.getStartDate() != null ? tripPlan.getStartDate().toString() : null) .endDate(tripPlan.getEndDate() != null ? tripPlan.getEndDate().toString() : null) diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/dto/TripPlanResponse.java b/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/dto/TripPlanResponse.java index ae8cc3f1..1da008ff 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/dto/TripPlanResponse.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/dto/TripPlanResponse.java @@ -10,6 +10,7 @@ public class TripPlanResponse { private Long id; // 여행 계획 ID private Long roomId; private Long masterId; + private Long voteRoomId; private String location; // 여행 장소 private String description; private String startDate; diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/service/TripPlanService.java b/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/service/TripPlanService.java index cfc8b10d..4f78febc 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/service/TripPlanService.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/tripPlan/service/TripPlanService.java @@ -70,9 +70,16 @@ private void linkTripPlanToRoomMembers(TripPlan tripPlan, Room room) { } /** - * 🚀 여행 계획이 생성되면 자동으로 투표방을 생성하는 메서드 + * 여행 계획이 생성되면 자동으로 투표방을 생성하는 메서드 */ private void createVoteRoomForTrip(TripPlan tripPlan) { + + // 코스 계획(COURSE)일 경우 투표방을 만들지 않고 ONGOING으로 + if (tripPlan.getTripPlanType() == TripPlanType.COURSE) { + tripPlan.setStatus(Status.ONGOING); + return; + } + // 기존에 존재하는 투표방이 있는지 확인 (중복 생성 방지) if (voteRoomRepository.findByTripPlanId(tripPlan.getId()).isPresent()) { throw new BusinessException(ErrorCode.VOTE_ROOM_ALREADY_EXISTS); diff --git a/src/main/java/com/umc/yeogi_gal_lae/api/vote/controller/VoteController.java b/src/main/java/com/umc/yeogi_gal_lae/api/vote/controller/VoteController.java index d7ef8c2a..e946a004 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/api/vote/controller/VoteController.java +++ b/src/main/java/com/umc/yeogi_gal_lae/api/vote/controller/VoteController.java @@ -1,5 +1,8 @@ package com.umc.yeogi_gal_lae.api.vote.controller; +import com.umc.yeogi_gal_lae.api.tripPlan.domain.TripPlan; +import com.umc.yeogi_gal_lae.api.tripPlan.repository.TripPlanRepository; +import com.umc.yeogi_gal_lae.api.tripPlan.types.TripPlanType; import com.umc.yeogi_gal_lae.api.vote.AuthenticatedUserUtils; import com.umc.yeogi_gal_lae.api.vote.dto.request.VoteRequest; import com.umc.yeogi_gal_lae.api.vote.dto.VoteResponse; @@ -7,6 +10,7 @@ import com.umc.yeogi_gal_lae.api.vote.service.ValidVoteResultService; import com.umc.yeogi_gal_lae.api.vote.service.VoteService; import com.umc.yeogi_gal_lae.global.common.response.Response; +import com.umc.yeogi_gal_lae.global.error.BusinessException; import com.umc.yeogi_gal_lae.global.error.ErrorCode; import com.umc.yeogi_gal_lae.global.success.SuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -31,6 +35,7 @@ public class VoteController { @Autowired private final VoteService voteService; private final ValidVoteResultService validVoteResultService; + private final TripPlanRepository tripPlanRepository; @Operation(summary = "투표하고자 하는 여행 계획의 정보 조회 API", description = "현재 투표의 여행 계획 정보에 해당합니다.") @GetMapping("/vote/trip-info") @@ -50,6 +55,13 @@ public Response getTripPlanInfoForVote(@RequestParam @ public Response createVote(@RequestBody @Valid VoteRequest.createVoteReq voteRequest) { String userEmail = AuthenticatedUserUtils.getAuthenticatedUserEmail(); + TripPlan tripPlan = tripPlanRepository.findById(voteRequest.getTripId()) + .orElseThrow(() -> new BusinessException(ErrorCode.TRIP_PLAN_NOT_FOUND)); + + // 코스 계획(COURSE)일 경우 투표 불가능 + if (tripPlan.getTripPlanType() == TripPlanType.COURSE) { + throw new BusinessException(ErrorCode.VOTE_NOT_ALLOWED_FOR_COURSE); + } voteRequest.setUserEmail(userEmail); voteService.createVote(voteRequest, userEmail); diff --git a/src/main/java/com/umc/yeogi_gal_lae/global/config/SecurityConfig.java b/src/main/java/com/umc/yeogi_gal_lae/global/config/SecurityConfig.java index 99a4c308..fdde68cf 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/global/config/SecurityConfig.java +++ b/src/main/java/com/umc/yeogi_gal_lae/global/config/SecurityConfig.java @@ -40,7 +40,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, UserRepository ) .addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); - return http.build(); } } diff --git a/src/main/java/com/umc/yeogi_gal_lae/global/error/ErrorCode.java b/src/main/java/com/umc/yeogi_gal_lae/global/error/ErrorCode.java index 737cbb28..337913ab 100644 --- a/src/main/java/com/umc/yeogi_gal_lae/global/error/ErrorCode.java +++ b/src/main/java/com/umc/yeogi_gal_lae/global/error/ErrorCode.java @@ -42,6 +42,7 @@ public enum ErrorCode implements BaseStatus { VOTE_NOT_COMPLETED_YET(HttpStatus.BAD_REQUEST, "VOTE_400", "아직 투표가 종료되지 않았습니다."), VOTE_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "VOTE_401", "요청 하신 투표 방을 찾을 수 없습니다."), VOTE_RESULT_FAILED(HttpStatus.BAD_REQUEST, "VOTE_403", "여행 확정에 실패하셨습니다. 이 방은 사라집니다."), + VOTE_NOT_ALLOWED_FOR_COURSE(HttpStatus.BAD_REQUEST, "VOTE_404", "코스는 투표가 허용되지 않습니다."), // Room Member Error ROOM_MEMBER_NOT_EXIST(HttpStatus.BAD_REQUEST, "ROOM_MEMBER_404", "방에 멤버가 존재하지 않습니다."), diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0fda36e0..2471fbc5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -51,7 +51,7 @@ spring: jwt: secret: ${JWT_SECRET} - access-token-validity: 1800000 # 30분 + access-token-validity: 86400000 # 30분 refresh-token-validity: 1209600000 # 2주 access: header: Authorization