diff --git a/.github/workflows/require-codeowners-approval.yml b/.github/workflows/require-codeowners-approval.yml index e7e2eff..3035b4d 100644 --- a/.github/workflows/require-codeowners-approval.yml +++ b/.github/workflows/require-codeowners-approval.yml @@ -30,7 +30,7 @@ jobs: } }); - const codeOwners = ['mingmingmon', 'mungsil']; + const codeOwners = ['mungsil']; const isAuthorCodeOwner = codeOwners.includes(prAuthor); let requiredApprovals = []; diff --git a/README.md b/README.md index 7754165..d083911 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ No Car 서버는 **자가용 없이**도 **편리하고 즐거운 여행**을 할 수 있도록 지원하여, 더 많은 사람들이 환경을 생각하며 여행을 즐길 수 있도록 하는 것을 목표로 합니다. +--- +## 🛠️ 시스템 아키텍처 +![모하고노카_다이어그램 drawio](https://github.com/user-attachments/assets/34d2823f-b50d-494f-8e00-6c83302895b6) + + + --- ## 👨🏻‍💻 Developer diff --git a/build.gradle b/build.gradle index c516923..2115e0b 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.3.4' id 'io.spring.dependency-management' version '1.1.6' + id "io.sentry.jvm.gradle" version "5.12.2" } group = 'com.example' @@ -14,6 +15,10 @@ java { } configurations { + all { + exclude group: "commons-logging", module: "commons-logging" + exclude group: "org.slf4j", module: "slf4j-simple" + } compileOnly { extendsFrom annotationProcessor } @@ -27,19 +32,24 @@ dependencies { // Web implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web-services' + implementation 'org.springframework.boot:spring-boot-starter-webflux' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' // HTTP Client implementation group: 'org.apache.httpcomponents.client5', name: 'httpclient5', version: '5.3.1' + // logging + implementation 'io.sentry:sentry-logback:8.22.0' + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' // DB - implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly 'org.postgresql:postgresql' + implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.12.0' // Actuator implementation 'org.springframework.boot:spring-boot-starter-actuator' @@ -47,12 +57,31 @@ dependencies { // valid implementation 'org.springframework.boot:spring-boot-starter-validation' + // retry + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' + + implementation 'net.logstash.logback:logstash-logback-encoder:9.0' + + // notification + implementation 'com.google.firebase:firebase-admin:9.7.0' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'it.ozimov:embedded-redis:0.7.3' + runtimeOnly 'com.h2database:h2' + testImplementation 'org.assertj:assertj-core:3.27.6' + implementation 'net.datafaker:datafaker:2.5.3' } tasks.named('test') { useJUnitPlatform() } + +test { + testLogging { + showStandardStreams = true + } +} diff --git a/gradlew.bat b/gradlew.bat index 9d21a21..5c644b7 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -49,7 +49,7 @@ echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo coordinate of your Java installation. 1>&2 goto fail @@ -63,7 +63,7 @@ echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo coordinate of your Java installation. 1>&2 goto fail diff --git a/lombock.config b/lombock.config new file mode 100644 index 0000000..eb6db90 --- /dev/null +++ b/lombock.config @@ -0,0 +1 @@ +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh index e3cc7f1..1c9ac2f 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -2,28 +2,75 @@ cd /home/ubuntu/app +# Redis 컨테이너 실행 여부 확인 +REDIS_CONTAINER_STATUS=$(docker ps | grep redis) +if [ -z "$REDIS_CONTAINER_STATUS" ]; then + echo "Redis 컨테이너가 실행 중이지 않습니다" + echo ">>> Pulling Redis image" + docker compose -f docker-compose.redis.yml pull redis + echo ">>> Starting Redis container" + docker compose -f docker-compose.redis.yml up -d redis +else + echo "Redis 컨테이너가 이미 실행 중입니다." +fi + + DOCKER_APP_NAME=spring -# 실행중인 blue가 있는지 -EXIST_BLUE=$(docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml ps | grep running) +# blue 컨테이너 실행 여부 확인 +BLUE_CONTAINER_RUNNING=$(docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml ps -q | xargs docker inspect -f '{{.State.Running}}') -# green이 실행중이면 blue up -if [ -z "$EXIST_BLUE" ]; then - echo "blue up" +# 컨테이너 스위칭 +if [ "$BLUE_CONTAINER_RUNNING" != "true" ]; then + echo "[blue up]" docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml up -d --build - sleep 30 - - docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml down - docker image prune -af # 사용하지 않는 이미지 삭제 + ACTIVE_ENV="blue" + STANDBY_ENV="green" + ACTIVE_PORT="8081" -# blue가 실행중이면 green up else - echo "green up" + echo "[green up]" docker-compose -p ${DOCKER_APP_NAME}-green -f docker-compose.green.yml up -d --build - sleep 30 + ACTIVE_ENV="green" + STANDBY_ENV="blue" + ACTIVE_PORT="8082" + +fi + + +# 헬스체크 +MAX_RETRIES=30 +RETRY_INTERVAL=2 +HEALTH_CHECK_URL="http://localhost:$ACTIVE_PORT/actuator/health" +ACTIVE_HEALTH_CHECK_PASSED=false + +echo "Waiting for application to be ready..." +for i in $(seq 1 $MAX_RETRIES); do + if curl -s "$HEALTH_CHECK_URL" | grep -q "UP"; then + echo "[Application is ready]" + ACTIVE_HEALTH_CHECK_PASSED=true + break + + fi + echo "Waiting... ($i/$MAX_RETRIES)" + sleep $RETRY_INTERVAL + +done + +# 새로운 컨테이너가 제대로 떴는지 확인 +if [ "$ACTIVE_HEALTH_CHECK_PASSED" = "true" ]; then + # 이전 컨테이너 종료 + docker-compose -p ${DOCKER_APP_NAME}-${STANDBY_ENV} -f docker-compose.${STANDBY_ENV}.yml down + docker image prune -af + echo "[Down] $STANDBY_ENV Down" + +else + echo "Health check failed after $MAX_RETRIES attempts" + # 새 컨테이너 종료 + docker-compose -p ${DOCKER_APP_NAME}-${ACTIVE_ENV} -f docker-compose.${ACTIVE_ENV}.yml down + # 디스코드 알림 + sudo ./discord_${ACTIVE_ENV}.sh - docker-compose -p ${DOCKER_APP_NAME}-blue -f docker-compose.blue.yml down - docker image prune -af fi \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/MohagoNocarApplication.java b/src/main/java/com/example/mohago_nocar/MohagoNocarApplication.java index f3671bc..a7849dc 100644 --- a/src/main/java/com/example/mohago_nocar/MohagoNocarApplication.java +++ b/src/main/java/com/example/mohago_nocar/MohagoNocarApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class MohagoNocarApplication { diff --git a/src/main/java/com/example/mohago_nocar/course/application/CourseErrorCode.java b/src/main/java/com/example/mohago_nocar/course/application/CourseErrorCode.java new file mode 100644 index 0000000..a711c18 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/CourseErrorCode.java @@ -0,0 +1,20 @@ +package com.example.mohago_nocar.course.application; + +import com.example.mohago_nocar.global.common.exception.Status; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum CourseErrorCode implements Status { + TRAVEL_COURSE_OPTIMIZATION_INCOMPLETE(HttpStatus.BAD_REQUEST, "TRAVEL_COURSE_OPTIMIZATION_INCOMPLETE", "여행 코스 최적화가 완료되지 않았습니다.") + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/CourseService.java b/src/main/java/com/example/mohago_nocar/course/application/CourseService.java deleted file mode 100644 index 0d91390..0000000 --- a/src/main/java/com/example/mohago_nocar/course/application/CourseService.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.mohago_nocar.course.application; - -import com.example.mohago_nocar.course.domain.service.CourseUseCase; -import org.springframework.stereotype.Service; - -@Service -public class CourseService implements CourseUseCase { -} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseConverter.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseConverter.java new file mode 100644 index 0000000..af0f918 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseConverter.java @@ -0,0 +1,68 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.course.application.dto.*; +import com.example.mohago_nocar.course.application.dto.RouteStepDto.LocationDto; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.BusPath; +import com.example.mohago_nocar.transit.domain.model.SubwayPath; +import com.example.mohago_nocar.transit.domain.model.WalkPath; + +import java.util.List; +import java.util.stream.Collectors; + +public class TravelCourseConverter { + + public static RouteStepDto convertToRouteStepDto( + final TravelSpot origin, + final TravelSpot destination, + final RouteStep step + ){ + return new RouteStepDto( + step.getTimeTakenMin(), + step.getDistanceKm(), + convertToLocationDto(origin), + convertToLocationDto(destination), + convertToSubPathDtos(step)); + } + + private static List convertToSubPathDtos(RouteStep step) { + return step.getDetailPaths().stream() + .map(path ->{ + if (path instanceof WalkPath walk) { + return new WalkSubPathDto(walk.getDistanceKm(), walk.getTimeTakenMin(), + walk.getPathType().name()); + } + + if (path instanceof BusPath bus) { + return new BusSubPathDto(bus.getDistanceKm(), bus.getTimeTakenMin(), + bus.getPathType().name(), bus.getBusNo(), bus.getBusType(), + bus.getStartName(), bus.getStartCoordinate().getLongitude(), bus.getStartCoordinate().getLatitude(), + bus.getEndName(), bus.getEndCoordinate().getLongitude(), bus.getEndCoordinate().getLatitude()); + } + + if (path instanceof SubwayPath subway) { + return new SubwaySubPathDto( + subway.getDistanceKm(), subway.getTimeTakenMin(), + subway.getPathType().name(), subway.getSubwayLineName(), + subway.getStartName(), subway.getStartCoordinate().getLongitude(), subway.getStartCoordinate().getLatitude(), + subway.getEndName(), subway.getEndCoordinate().getLongitude(), subway.getEndCoordinate().getLatitude() + ); + } + + throw new IllegalArgumentException("지원하지 않는 경로 유형입니다."); + }) + .collect(Collectors.toList()); + } + + private static LocationDto convertToLocationDto(TravelSpot origin) { + Location location = origin.getLocation(); + return LocationDto.builder() + .name(location.getName()) + .latitude(location.getCoordinate().getLatitude()) + .longitude(location.getCoordinate().getLongitude()) + .build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventHandler.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventHandler.java new file mode 100644 index 0000000..5e01562 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventHandler.java @@ -0,0 +1,71 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseCompletionMessage; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventHandleHistory; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; +import com.example.mohago_nocar.course.domain.repository.TravelCourseOptimizedEventHandleHistoryRepository; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationDto; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationOutboxService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Component +@Slf4j +@RequiredArgsConstructor +public class TravelCourseEventHandler { + + private final TravelCourseUseCase travelCourseUseCase; + private final TravelCourseOptimizedEventHandleHistoryRepository handleHistoryRepository; + private final UserNotificationOutboxService notificationOutboxService; + + @Transactional + public void handleEvent(TravelCourseOptimizedEvent event) { + travelCourseUseCase.generateTransitRoute(event.getTravelCourseId()); + processSuccessEvent(event); + } + + private void processSuccessEvent(TravelCourseOptimizedEvent event) { + travelCourseUseCase.updateUncompletedCourseStatus(event.getTravelCourseId(), TravelCourseStatus.SUCCEEDED); + UserNotificationDto notificationDto = createNotificationMessage(event, true); + notificationOutboxService.save(notificationDto); + } + + @Transactional + public void processFailEvent(TravelCourseOptimizedEvent event) { + travelCourseUseCase.updateUncompletedCourseStatus(event.getTravelCourseId(), TravelCourseStatus.FAILED); + UserNotificationDto notificationDto = createNotificationMessage(event, false); + notificationOutboxService.save(notificationDto); + } + + private UserNotificationDto createNotificationMessage(TravelCourseOptimizedEvent event, boolean isSuccess) { + TravelCourseCompletionMessage message = isSuccess ? + TravelCourseCompletionMessage.SUCCESS : TravelCourseCompletionMessage.FAILURE; + + return new UserNotificationDto( + message.getTitle(), + message.getBody(), + event.getAnonymousUserId(), + Map.of("travelCourseId", String.valueOf(event.getTravelCourseId())) + ); + } + + public boolean hasHandleHistory(Long travelCourseId) { + TravelCourseOptimizedEventHandleHistory history = TravelCourseOptimizedEventHandleHistory.of(travelCourseId); + + try { + handleHistoryRepository.save(history); + } catch (DataIntegrityViolationException e) { + return true; + } + + return false; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxHandler.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxHandler.java new file mode 100644 index 0000000..7cdc745 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxHandler.java @@ -0,0 +1,38 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventOutbox; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Service +@Slf4j +@RequiredArgsConstructor +public class TravelCourseEventOutboxHandler { + + private final TravelCourseEventOutboxService outboxService; + + @Scheduled(fixedDelay = 1000) + public void handle() { + long handleStartTime = System.nanoTime(); + + List unpublishedList = outboxService.findUnpublished(10); + for (TravelCourseOptimizedEventOutbox unpublished : unpublishedList) { + try { + outboxService.publish(unpublished); + outboxService.markAsPublished(unpublished); + } catch (Exception e) { + outboxService.processFailure(unpublished, e); + } + } + + long handleEndTime = System.nanoTime(); + log.info("Handle completed in {} ms", TimeUnit.NANOSECONDS.toMillis(handleEndTime - handleStartTime)); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxService.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxService.java new file mode 100644 index 0000000..3760e43 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseEventOutboxService.java @@ -0,0 +1,61 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventOutbox; +import com.example.mohago_nocar.course.domain.repository.TravelCourseEventOutboxRepository; +import com.example.mohago_nocar.course.infrastructure.course.messaging.TravelCourseOptimizedEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class TravelCourseEventOutboxService { + + private final TravelCourseEventOutboxRepository travelCourseEventOutboxRepository; + private final TravelCourseOptimizedEventPublisher eventPublisher; + + @Transactional + public TravelCourseOptimizedEventOutbox generate(TravelCourse course) { + TravelCourseOptimizedEventOutbox courseOutbox = createCourseOutbox(course); + return travelCourseEventOutboxRepository.save(courseOutbox); + } + + private TravelCourseOptimizedEventOutbox createCourseOutbox(TravelCourse course) { + TravelCourseOptimizedEvent event = TravelCourseOptimizedEvent.of(course.getId(), course.getAnonymousUserId()); + return TravelCourseOptimizedEventOutbox.create(event); + } + + public List findUnpublished(int size) { + return travelCourseEventOutboxRepository.findByStatusInOrderByCreatedDateAsc( + List.of(OutboxStatus.PENDING), size); + } + + public void publish(TravelCourseOptimizedEventOutbox travelCourseOptimizedEventOutbox) { + eventPublisher.publish(travelCourseOptimizedEventOutbox.getPayload()); + } + + @Transactional + public void markAsPublished(TravelCourseOptimizedEventOutbox outbox) { + outbox.markAsPublished(); + travelCourseEventOutboxRepository.save(outbox); + } + + @Transactional + public void processFailure(TravelCourseOptimizedEventOutbox outbox, Throwable throwable) { + if (outbox.isFinalRetry()) { + outbox.markFailWithReason(OutboxStatus.FAIL, throwable); + log.error("최대 재시도 횟수에 도달했습니다. outbox id: {}", outbox.getId()); + } else { + outbox.incrementRetryCount(); + } + travelCourseEventOutboxRepository.save(outbox); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java new file mode 100644 index 0000000..9366492 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/course/TravelCourseService.java @@ -0,0 +1,149 @@ +package com.example.mohago_nocar.course.application.course; + +import com.example.mohago_nocar.course.application.CourseErrorCode; +import com.example.mohago_nocar.course.application.dto.RouteStepDto; +import com.example.mohago_nocar.course.application.route.RouteFinder; +import com.example.mohago_nocar.course.application.route.RouteStepService; +import com.example.mohago_nocar.course.application.spot.TravelSpotService; +import com.example.mohago_nocar.course.domain.event.ThrottlingCompletedEvent; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.course.domain.repository.TravelCourseRepository; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.course.presentation.dto.CreateTravelCourseRequestDto; +import com.example.mohago_nocar.course.presentation.dto.CreateOptimizedTravelCourseAcceptedResponseDto; +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import com.example.mohago_nocar.user.domain.AnonymousUser; +import com.example.mohago_nocar.user.domain.UserUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class TravelCourseService implements TravelCourseUseCase { + + private final UserUseCase userUseCase; + private final TravelCourseRepository travelCourseRepository; + private final TravelCourseEventOutboxService travelCourseEventOutboxService; + private final TravelSpotService travelSpotService; + private final RouteStepService routeStepService; + private final ApplicationEventPublisher eventPublisher; + private final RouteFinder routeFinder; + + @Override + @Transactional + public CreateOptimizedTravelCourseAcceptedResponseDto createOptimizedTravelCourse(CreateTravelCourseRequestDto request) { + AnonymousUser user = userUseCase.getOrCreate(request.fcmToken()); + TravelCourse course = TravelCourse.create(user, TravelCourseStatus.PENDING); + travelCourseRepository.save(course); + + generateSpotsWithOptimizedOrder(request, course); + + travelCourseEventOutboxService.generate(course); + return CreateOptimizedTravelCourseAcceptedResponseDto.of(course.getId(), user.getId()); + } + + private void generateSpotsWithOptimizedOrder(CreateTravelCourseRequestDto request, TravelCourse course) { + Set spotsWithoutVisitOrder = + travelSpotService.makeSpotsWithoutOrder(course, request.festivalId(), request.travelStartDate(), request.placeIds()); + + Set spotsWithOrder = + travelSpotService.determineOptimizedTravelOrder(spotsWithoutVisitOrder); + + travelSpotService.saveAll(spotsWithOrder); + } + + @Override + @Transactional + public void generateTransitRoute(Long travelCourseId) { + List> routeFutures = findRoutesInTravelCourse(travelCourseId); + eventPublisher.publishEvent(ThrottlingCompletedEvent.of(travelCourseId)); + List routeSteps = CompletableFuture.allOf(routeFutures.toArray(new CompletableFuture[0])) + .orTimeout(8, TimeUnit.SECONDS) + .thenApply(completed -> routeFutures.stream().map(CompletableFuture::join).toList()) + .join(); + + routeStepService.saveAll(routeSteps); + } + + private List> findRoutesInTravelCourse(Long travelCourseId) { + List travelSpots = travelSpotService.getByCourseId(travelCourseId); + + validateMinSize(travelSpots, 2); + sortByVisitOrder(travelSpots); + + return routeFinder.findRouteWithThrottling(travelSpots); + } + + private void sortByVisitOrder(List travelSpots) { + Collections.sort(travelSpots); + } + + private void validateMinSize(List travelSpotsInOrder, int minSize) { + if (travelSpotsInOrder == null || travelSpotsInOrder.size() < minSize) { + System.out.println("travelSpotInOrder: " + travelSpotsInOrder); + throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); + } + } + + @Override + public List getOptimizedTravelCourseRoutes(Long courseId, UUID ownerUserId) { + Objects.requireNonNull(courseId); + Objects.requireNonNull(ownerUserId); + + TravelCourse course = travelCourseRepository.findById(courseId) + .orElseThrow(() -> new CustomException(GlobalStatus.ENTITY_NOT_FOUND)); + + if (!course.getAnonymousUserId().equals(ownerUserId)) { + throw new CustomException(GlobalStatus.FORBIDDEN); + } + + if (course.getCourseStatus() != TravelCourseStatus.SUCCEEDED) { + throw new CustomException(CourseErrorCode.TRAVEL_COURSE_OPTIMIZATION_INCOMPLETE); + } + + List travelSpots = travelSpotService.getByCourseId(course.getId()); + Collections.sort(travelSpots); + + List routesBetweenSpots = new ArrayList<>(); + for (int i = 0; i < travelSpots.size() - 1; i++) { + TravelSpot origin = travelSpots.get(i); + TravelSpot destination = travelSpots.get(i + 1); + RouteStep route = routeStepService.getByOriginAndDestination(origin, destination); + RouteStepDto dto = TravelCourseConverter.convertToRouteStepDto(origin, destination, route); + routesBetweenSpots.add(dto); + } + + return routesBetweenSpots; + } + + @Override + public Optional findById(Long travelCourseId) { + return travelCourseRepository.findById(travelCourseId); + } + + @Override + @Transactional + public void updateUncompletedCourseStatus(Long travelCourseId, TravelCourseStatus courseStatus) { + TravelCourse course = findById(travelCourseId).orElseThrow(); + if (course.getCourseStatus().isComplete()) { + throw new RuntimeException("이미 처리 완료된 여행 코스입니다. " + course.toString()); + } + + course.updateStatus(courseStatus); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/BusSubPathDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/BusSubPathDto.java new file mode 100644 index 0000000..53331fa --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/BusSubPathDto.java @@ -0,0 +1,17 @@ +package com.example.mohago_nocar.course.application.dto; + +public record BusSubPathDto( + double distanceKm, + int timeTakenMin, + String pathType, + String busNo, + int busType, + String startPlaceName, + double startLongitude, + double startLatitude, + String endPlaceName, + double endLongitude, + double endLatitude +) implements SubPathDto{ + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleFailure.java b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleFailure.java new file mode 100644 index 0000000..be0bb48 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleFailure.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.course.application.dto; + +public final class EventHandleFailure implements EventHandleResult { + + private Throwable throwable; + + public static EventHandleFailure create(Throwable throwable) { + return new EventHandleFailure(throwable); + } + + private EventHandleFailure(Throwable throwable) { + this.throwable = throwable; + } + + public Throwable get() { + return throwable; + } +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleResult.java b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleResult.java new file mode 100644 index 0000000..af3d107 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleResult.java @@ -0,0 +1,7 @@ +package com.example.mohago_nocar.course.application.dto; + +public sealed interface EventHandleResult permits + EventHandleSuccess, + EventHandleFailure { + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleSuccess.java b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleSuccess.java new file mode 100644 index 0000000..e383e62 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/EventHandleSuccess.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.course.application.dto; + +import java.util.concurrent.CompletableFuture; + +public final class EventHandleSuccess implements EventHandleResult { + + private CompletableFuture futureResult; + + public static EventHandleSuccess create(CompletableFuture futureResult) { + return new EventHandleSuccess(futureResult); + } + + private EventHandleSuccess(CompletableFuture futureResult) { + this.futureResult = futureResult; + } + + public CompletableFuture get() { + return futureResult; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/GetRequesterInfoDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/GetRequesterInfoDto.java new file mode 100644 index 0000000..12dba86 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/GetRequesterInfoDto.java @@ -0,0 +1,10 @@ +package com.example.mohago_nocar.course.application.dto; + +import java.util.UUID; + +// todo 삭제 +public record GetRequesterInfoDto( + UUID anonymousUserId, + String fcmToken +) { +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/RouteStepDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/RouteStepDto.java new file mode 100644 index 0000000..44fa884 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/RouteStepDto.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.course.application.dto; + +import lombok.Builder; + +import java.util.List; + +public record RouteStepDto ( + int sectionTimeMin, + double sectionDistanceKm, + LocationDto origin, + LocationDto destination, + List subPaths +){ + + public static RouteStepDto of( + int sectionTimeMin, + double sectionDistanceKm, + LocationDto origin, + LocationDto destination, + List subPaths + ) { + return new RouteStepDto(sectionTimeMin, sectionDistanceKm, origin, destination, subPaths); + } + + @Builder + public record LocationDto( + String name, + Double longitude, + Double latitude + ){ + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/SubPathDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/SubPathDto.java new file mode 100644 index 0000000..d76ddd6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/SubPathDto.java @@ -0,0 +1,4 @@ +package com.example.mohago_nocar.course.application.dto; + +public interface SubPathDto { +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/SubwaySubPathDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/SubwaySubPathDto.java new file mode 100644 index 0000000..0666a60 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/SubwaySubPathDto.java @@ -0,0 +1,16 @@ +package com.example.mohago_nocar.course.application.dto; + +public record SubwaySubPathDto( + double distanceKm, + int timeTakenMin, + String pathType, + String subwayLineName, + String startPlaceName, + double startLongitude, + double startLatitude, + String endPlaceName, + double endLongitude, + double endLatitude +) implements SubPathDto{ + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/TravelCourseResultNotificationDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/TravelCourseResultNotificationDto.java new file mode 100644 index 0000000..728a195 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/TravelCourseResultNotificationDto.java @@ -0,0 +1,20 @@ +package com.example.mohago_nocar.course.application.dto; + +import lombok.Builder; + +import java.util.UUID; + +@Builder +public record TravelCourseResultNotificationDto( + Long travelCourseId, + UUID anonymousUserId +) { + + public static TravelCourseResultNotificationDto of( + Long travelCourseId, + UUID anonymousUserId + ) { + return new TravelCourseResultNotificationDto(travelCourseId, anonymousUserId); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/dto/WalkSubPathDto.java b/src/main/java/com/example/mohago_nocar/course/application/dto/WalkSubPathDto.java new file mode 100644 index 0000000..ac43363 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/dto/WalkSubPathDto.java @@ -0,0 +1,12 @@ +package com.example.mohago_nocar.course.application.dto; + +import com.example.mohago_nocar.transit.domain.model.PathType; + +public record WalkSubPathDto( + double distanceKm, + int timeTakenMin, + String pathType +) implements SubPathDto { + + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/route/RouteFinder.java b/src/main/java/com/example/mohago_nocar/course/application/route/RouteFinder.java new file mode 100644 index 0000000..3af3264 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/route/RouteFinder.java @@ -0,0 +1,99 @@ +package com.example.mohago_nocar.course.application.route; + +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.locks.ReentrantLock; + +@Component +@Slf4j +@RequiredArgsConstructor +public class RouteFinder { + + private final TransitRouteApiAdapter transitRouteApiAdapter; + + private ReentrantLock lock = new ReentrantLock(true); + + public List> findRouteWithThrottling(List travelSpots) { + List> steps = new ArrayList<>(); + + for (int i = 0; i < travelSpots.size()-1; i++) { + TravelSpot originSpot = travelSpots.get(i); + TravelSpot destinationSpot = travelSpots.get(i + 1); + + CompletableFuture routeFuture = + transitRouteApiAdapter.getTransitRouteWithThrottling(originSpot.getLocation(), destinationSpot.getLocation()) + .thenApply(transitRoute -> RouteStep.from(originSpot, destinationSpot, transitRoute)); + + steps.add(routeFuture); + } + + return steps; + } + + /** + * 여행 코스 내 존재하는 장소 사이의 이동 경로를 찾습니다. + * @param travelSpots 같은 여행 코스 내 방문 순서가 정해진 여행 장소들 + * @return 출발 장소, 도착 장소 간의 이동 경로들 + * @implNote 이동 경로 조회 API가 허용하는 초당 호출량은 최대 5회입니다. + * 따라서 병렬적으로 여러 여행 코스 내의 이동 경로를 구할 경우, 전체적인 처리 시간이 증가합니다. + * 이를 방지하기 위해 한 여행 코스 내의 이동 경로 조회 API를 호출한 후에 다음 여행 코스의 이동 경로를 구하도록 제한합니다. + */ + public List> findRouteInTravelCourse(Long travelCourseId, List travelSpots) { + validateMinSize(travelSpots, 2); + validateSpotBelongsToSameCourse(travelCourseId, travelSpots); + sortByVisitOrder(travelSpots); + + List> steps = new ArrayList<>(); + + try{ + lock.lock(); + log.info("get Lock in findRouteInTravelCourse"); + + for (int i = 0; i < travelSpots.size()-1; i++) { + TravelSpot originSpot = travelSpots.get(i); + TravelSpot destinationSpot = travelSpots.get(i + 1); + + CompletableFuture routeFuture = + transitRouteApiAdapter.getTransitRouteWithThrottling(originSpot.getLocation(), destinationSpot.getLocation()) + .thenApply(transitRoute -> RouteStep.from(originSpot, destinationSpot, transitRoute)); + + steps.add(routeFuture); + } + + } finally { + lock.unlock(); + log.info("release Lock in findRouteInTravelCourse"); + } + + return steps; + } + + private void validateSpotBelongsToSameCourse(Long travelCourseId, List travelSpots) { + for (TravelSpot travelSpot : travelSpots) { + if (!Objects.equals(travelSpot.getCourseId(), travelCourseId)) { + log.error("여행 장소가 속한 코스의 아이디가 {}입니다. 그러나 여행 코스 아이디는 {} 이어야 합니다.", + travelSpot.getCourseId(), travelCourseId); + throw new RuntimeException("여행 장소들이 모두 같은 여행 코스에 소속되지 않았습니다."); + } + } + } + + private void sortByVisitOrder(List travelSpots) { + Collections.sort(travelSpots); + } + + private void validateMinSize(List travelSpotsInOrder, int minSize) { + if (travelSpotsInOrder == null || travelSpotsInOrder.size() < minSize) { + System.out.println("travelSpotInOrder: " + travelSpotsInOrder); + throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java b/src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java new file mode 100644 index 0000000..80e7e49 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/route/RouteStepService.java @@ -0,0 +1,54 @@ +package com.example.mohago_nocar.course.application.route; + +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.course.domain.repository.RouteStepRepository; +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +@Service +@Slf4j +@RequiredArgsConstructor +public class RouteStepService { + + private final RouteStepRepository routeStepRepository; + + @Transactional + public List saveAll(List routeSteps) { + return routeStepRepository.saveAll(routeSteps); + } + + // todo 예외 처리, 메서드 이름이 올바른가 점검 + private CompletableFuture saveWhenAllCompleteWithTimeout(List> routeStepFutures, int timeoutInSec) { + return waitAllCompleteWithTimeout(routeStepFutures, timeoutInSec) + .thenRun(() -> { + List routeSteps = routeStepFutures.stream().map(CompletableFuture::join).toList(); + routeStepRepository.saveAll(routeSteps); + }); + } + + private CompletableFuture waitAllCompleteWithTimeout(List> routeStepFutures, int timeoutInSec) { + return CompletableFuture.allOf(routeStepFutures.toArray(new CompletableFuture[routeStepFutures.size()])) + .orTimeout(timeoutInSec, TimeUnit.SECONDS); + } + + public RouteStep getByOriginAndDestination(TravelSpot origin, TravelSpot destination) { + return routeStepRepository.findByOriginAndDestination(origin.getId(), destination.getId()) + .orElseThrow(() -> { + log.error("주어진 출발지와 목적지 사이에 경로가 존재하지 않습니다. origin={}, destination={}", origin, destination); + return new CustomException(GlobalStatus.ENTITY_NOT_FOUND); + }); + } + + public List findAll(List ids) { + return routeStepRepository.findByIds(ids); + } +} diff --git a/src/main/java/com/example/mohago_nocar/course/application/spot/TravelSpotService.java b/src/main/java/com/example/mohago_nocar/course/application/spot/TravelSpotService.java new file mode 100644 index 0000000..a145b37 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/application/spot/TravelSpotService.java @@ -0,0 +1,180 @@ +package com.example.mohago_nocar.course.application.spot; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpotFestival; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpotPlace; +import com.example.mohago_nocar.course.domain.repository.TravelSpotRepository; +import com.example.mohago_nocar.course.domain.service.TravelSpotUseCase; +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import com.example.mohago_nocar.global.common.exception.InvalidValueException; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.service.PlaceUseCase; +import com.example.mohago_nocar.plan.application.v1.strategy.RouteOptimizationStrategy; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.domain.service.TransitRouteSummaryUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + + +import static com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode.TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TravelSpotService implements TravelSpotUseCase { + + private final TravelSpotRepository travelSpotRepository; + private final TransitRouteSummaryUseCase transitRouteSummaryUseCase; + private final RouteOptimizationStrategy routeOptimizationStrategy; + private final FestivalUseCase festivalUseCase; + private final PlaceUseCase placeUseCase; + + @Override + public Set determineOptimizedTravelOrder(Set unorderedSpots) { + if (unorderedSpots == null || unorderedSpots.isEmpty()) { + return Collections.emptySet(); + } + + // 중복 좌표를 갖는 장소 존재 가능 -> 임시 중복 좌표 제거 + Map> unorderedSpotsByCoordinate = mapByCoordinateFrom(unorderedSpots); + Set uniqueCoordinates = unorderedSpotsByCoordinate.keySet(); + + // 고유한 좌표들을 방문할 순서 결정 + List routeMetrics = fetchRouteMetrics(uniqueCoordinates); + List determinedOrder = routeOptimizationStrategy.calculateOptimalRoute( + uniqueCoordinates.stream().toList(), routeMetrics); + + // 중복된 좌표를 가졌던 장소를 포함한 모든 장소에게 방문 순서 부여 + return assignOrderToSpot(determinedOrder, unorderedSpotsByCoordinate); + } + + private Map> mapByCoordinateFrom(Set travelSpots) { + return travelSpots.stream() + .collect(Collectors.groupingBy( + travelSpot -> travelSpot.getLocation().getCoordinate(), + Collectors.toCollection(HashSet::new) + )); + } + + /** + * 모든 좌표 간의 이동 요약 정보(거리(km), 이동 시간(minutes))을 구합니다. + * + * @param coordinates 거리, 이동 시간을 구하는 대상 좌표 + * @return 좌표 간의 거리 및 이동시간

예시: A, B 좌표가 주어지면 A->B, B->A 간의 거리 및 이동 시간을 구합니다.

+ */ + private List fetchRouteMetrics(Set coordinates) { + List>> futureRouteMetricsNested = new ArrayList<>(); + + for (Coordinate origin : coordinates) { + Set destinations = new HashSet<>(coordinates); + destinations.remove(origin); + + futureRouteMetricsNested.add( + transitRouteSummaryUseCase.getRouteSummary(origin, destinations)); + } + + CompletableFuture allOf = CompletableFuture.allOf( + futureRouteMetricsNested.toArray(new CompletableFuture[0])) + .orTimeout(8, TimeUnit.SECONDS); + + try { + allOf.join(); + } catch (Exception e) { + log.error("이동 경로 요약 조회 실패", e); + throw new RuntimeException(e); + } + + return futureRouteMetricsNested.stream() + .map(CompletableFuture::join) + .flatMap(Collection::stream) + .toList(); + } + + private Set assignOrderToSpot( + List visitOrder, Map> spotsByCoordinate + ) { + if (visitOrder.size() != spotsByCoordinate.keySet().size()) { // todo map.size 동작 + log.error("경위도 개수가 일치하지 않습니다. visitOrder- {}개, spotsByCoordinate- {}개", visitOrder.size(), spotsByCoordinate.size()); + throw new CustomException(GlobalStatus.INTERNAL_SERVER_ERROR); + } + + int order = 0; + Set orderedSpot = new HashSet<>(); + for (Coordinate nxt : visitOrder) { + Set nxtSpots = spotsByCoordinate.get(nxt); + for (TravelSpot spot : nxtSpots) { + spot.setOrder(order); + order = order + 1; + } + orderedSpot.addAll(nxtSpots); + } + + return orderedSpot; + } + + @Override + public List saveAll(Set spotsWithOrder) { + return travelSpotRepository.saveAll(spotsWithOrder); + } + + @Override + public List getByCourseId(Long travelCourseId) { + return travelSpotRepository.findByTravelCourseId(travelCourseId); + } + + @Override + public Set makeSpotsWithoutOrder(TravelCourse ownerCourse, Long festivalId, LocalDate travelDate, List placeIds) { + Festival festival = getFestivalOrThrow(festivalId, travelDate); + List places = getNearPlacesOfFestival(festival, placeIds); + + return createUnOrderedTravelSpots(ownerCourse, festival, places); + } + + private Festival getFestivalOrThrow(Long festivalId, LocalDate travelDate) { + var festival = festivalUseCase.getFestival(festivalId); +/* if (!festival.isOpen(travelDate)) { + throw new InvalidValueException(TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD); + }*/ + return festival; + } + + private List getNearPlacesOfFestival(Festival festival, List placeIds) { + List places = placeUseCase.getFestivalNearPlacesById(festival.getId(), placeIds); + log.info("카카오로부터 조회해온 장소:{}", places); + // todo refactoring : 캐싱 정책 (all or nothing)을 담는 객체 생성 + if (places.isEmpty()) { + log.info("Place cache miss가 발생했습니다. 원본 데이터를 요청하여 재캐싱합니다."); + places = placeUseCase.cachePlaces(festival.getId(), festival.getCoordinate()).stream() + .filter(place -> placeIds.contains(place.getKakaoId())) + .toList(); + } + return places; + } + + @Override + public Set createUnOrderedTravelSpots(TravelCourse course, Festival festival, List places) { + log.info("인자로 전달된 place 사이즈: {}", places.size()); + log.info("places:{}", places); + Set travelSpots = new HashSet<>(); + travelSpots.add(TravelSpotFestival.createWithNoOrder(course, festival)); + for (Place place : places) { + travelSpots.add(TravelSpotPlace.createWithNoOrder(course, place)); + } + log.info("여행 장소: {}", travelSpots); + + return travelSpots; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/event/ThrottlingCompletedEvent.java b/src/main/java/com/example/mohago_nocar/course/domain/event/ThrottlingCompletedEvent.java new file mode 100644 index 0000000..77cec84 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/event/ThrottlingCompletedEvent.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.course.domain.event; + +import lombok.Getter; + +@Getter +public class ThrottlingCompletedEvent { + + private final Long travelCourseId; + + public static ThrottlingCompletedEvent of(Long travelCourseId) { + return new ThrottlingCompletedEvent(travelCourseId); + } + + private ThrottlingCompletedEvent(Long travelCourseId) { + this.travelCourseId = travelCourseId; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java b/src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java new file mode 100644 index 0000000..8045a1b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/event/TravelCourseOptimizedEvent.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.course.domain.event; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.UUID; + +/** + * 여행 코스에 포함된 여행 장소들을 방문할 순서의 최적화 완료 이벤트 + */ +@Getter +@ToString +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelCourseOptimizedEvent { + + private Long travelCourseId; + private UUID anonymousUserId; + + public static TravelCourseOptimizedEvent of(Long travelCourseId, UUID anonymousUserId) { + return new TravelCourseOptimizedEvent(travelCourseId, anonymousUserId); + } + + private TravelCourseOptimizedEvent(Long travelCourseId, UUID anonymousUserId) { + this.travelCourseId = travelCourseId; + this.anonymousUserId = anonymousUserId; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/Course.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/Course.java deleted file mode 100644 index 146f361..0000000 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/course/Course.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.mohago_nocar.course.domain.model.course; - -import com.example.mohago_nocar.global.common.domain.BaseEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - -@Entity -@Getter -@NoArgsConstructor(access = PROTECTED) -public class Course extends BaseEntity { - - @Id - @GeneratedValue(strategy = IDENTITY) - private Long id; - - public static Course from() { - return new Course(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java new file mode 100644 index 0000000..6a9c600 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourse.java @@ -0,0 +1,50 @@ +package com.example.mohago_nocar.course.domain.model.course; + +import com.example.mohago_nocar.global.common.domain.BaseEntity; +import com.example.mohago_nocar.user.domain.AnonymousUser; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +@Table(name = "travel_course") +public class TravelCourse extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private UUID anonymousUserId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TravelCourseStatus courseStatus; + + public static TravelCourse create(AnonymousUser user, TravelCourseStatus courseStatus) { + return TravelCourse.builder() + .anonymousUserId(user.getId()) + .courseStatus(courseStatus) + .build(); + } + + @Builder + private TravelCourse(UUID anonymousUserId, TravelCourseStatus courseStatus) { + this.anonymousUserId = anonymousUserId; + this.courseStatus = courseStatus; + } + + public void updateStatus(TravelCourseStatus courseStatus) { + this.courseStatus = courseStatus; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseCompletionMessage.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseCompletionMessage.java new file mode 100644 index 0000000..9796aaa --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseCompletionMessage.java @@ -0,0 +1,28 @@ +package com.example.mohago_nocar.course.domain.model.course; + +public enum TravelCourseCompletionMessage { + + SUCCESS("여행 계획 완성! ✈️", "여행 계획 설계가 완료되었어요. 탭해서 확인해보세요!"), + FAILURE("여행 계획 수립 실패...", "예상치 못한 에러로 계획 수립에 실패했어요."); + + private final String title; + private final String body; + + TravelCourseCompletionMessage(String title, String body) { + this.title = title; + this.body = body; + } + + public String getTitle() { + return title; + } + + public String getBody() { + return body; + } + + public static TravelCourseCompletionMessage fromResult(boolean result) { + return result ? SUCCESS : FAILURE; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventHandleHistory.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventHandleHistory.java new file mode 100644 index 0000000..b3b07b9 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventHandleHistory.java @@ -0,0 +1,38 @@ +package com.example.mohago_nocar.course.domain.model.course; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Persistable; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelCourseOptimizedEventHandleHistory implements Persistable { + + @Id + @Column(unique = true) + private Long travelCourseId; + + public static TravelCourseOptimizedEventHandleHistory of(Long travelCourseId) { + return new TravelCourseOptimizedEventHandleHistory(travelCourseId); + } + + @Builder + private TravelCourseOptimizedEventHandleHistory(Long travelCourseId) { + this.travelCourseId = travelCourseId; + } + + @Override + public Long getId() { + return travelCourseId; + } + + @Override + public boolean isNew() { + return true; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventOutbox.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventOutbox.java new file mode 100644 index 0000000..2f5f33b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseOptimizedEventOutbox.java @@ -0,0 +1,65 @@ +package com.example.mohago_nocar.course.domain.model.course; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.common.domain.BaseEntity; +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelCourseOptimizedEventOutbox extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private TravelCourseOptimizedEvent payload; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OutboxStatus status; + + @Column(nullable = false) + private Integer retryCount; + + @Column(nullable = true) + private String failReason; + + public static TravelCourseOptimizedEventOutbox create(TravelCourseOptimizedEvent event) { + return TravelCourseOptimizedEventOutbox.builder() + .payload(event) + .retryCount(0) + .status(OutboxStatus.PENDING) + .build(); + } + + @Builder + private TravelCourseOptimizedEventOutbox(TravelCourseOptimizedEvent payload, OutboxStatus status, Integer retryCount) { + this.payload = payload; + this.status = status; + this.retryCount = retryCount; + } + + public boolean isFinalRetry() { + return 3 <= retryCount; + } + + public int incrementRetryCount() { + return ++retryCount; + } + + public void markAsPublished() { + this.status = OutboxStatus.SENT; + } + + public void markFailWithReason(OutboxStatus outboxStatus, Throwable throwable) { + this.status = outboxStatus; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseStatus.java b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseStatus.java new file mode 100644 index 0000000..a554246 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/course/TravelCourseStatus.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.course.domain.model.course; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum TravelCourseStatus { + PENDING("처리 대기 중"), + SUCCEEDED("처리 성공"), + FAILED("처리 실패"); + + private final String description; + + public boolean isComplete() { + return this == SUCCEEDED || this == FAILED; + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java b/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java index 701e1d7..e9ef6f0 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/routeStep/RouteStep.java @@ -1,21 +1,30 @@ package com.example.mohago_nocar.course.domain.model.routeStep; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; import com.example.mohago_nocar.global.common.domain.BaseEntity; -import com.example.mohago_nocar.global.util.DurationToIntervalConverter; +import com.example.mohago_nocar.transit.domain.model.SubPath; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.ToString; +import org.hibernate.annotations.Type; -import java.time.Duration; +import java.util.List; import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; @Entity @Getter +@Table(name = "route_step", + uniqueConstraints = + @UniqueConstraint(columnNames = {"originSpotId", "destinationSpotId"}) +) +@ToString @NoArgsConstructor(access = PROTECTED) public class RouteStep extends BaseEntity { @@ -23,57 +32,40 @@ public class RouteStep extends BaseEntity { @GeneratedValue(strategy = IDENTITY) private Long id; - @NotNull - private Long courseId; - - @NotNull - private Integer distance; + private Long originSpotId; - @NotNull - private Integer stepOrder; + private Long destinationSpotId; - @NotNull - @Embedded - @AttributeOverrides({ - @AttributeOverride(name = "longitude", column = @Column(name = "start_longitude")), - @AttributeOverride(name = "latitude", column = @Column(name = "start_latitude")) - }) - private Location startLocation; + @Type(JsonBinaryType.class) + @Column(columnDefinition = "jsonb") + private List detailPaths; @NotNull - @Embedded - @AttributeOverrides({ - @AttributeOverride(name = "longitude", column = @Column(name = "end_longitude")), - @AttributeOverride(name = "latitude", column = @Column(name = "end_latitude")) - }) - private Location endLocation; + private Double distanceKm; - @NotNull - @Convert(converter = DurationToIntervalConverter.class) - private Duration timeTaken; + private Integer timeTakenMin; - public static RouteStep from(Long courseId, Integer distance, Integer stepOrder, - Location startLocation, Location endLocation, Duration timeTaken + public static RouteStep from( + TravelSpot origin, TravelSpot destination, TransitRoute transitRoute ) { + List subPaths = transitRoute.getSubPaths(); return RouteStep.builder() - .courseId(courseId) - .distance(distance) - .stepOrder(stepOrder) - .startLocation(startLocation) - .endLocation(endLocation) - .timeTaken(timeTaken) + .originSpotId(origin.getId()) + .destinationSpotId(destination.getId()) + .timeTakenMin(transitRoute.getTotalTime()) + .distanceKm(transitRoute.getTotalDistance()) + .detailPaths(subPaths) .build(); } @Builder - private RouteStep(Long courseId, Integer distance, Integer stepOrder, - Location startLocation, Location endLocation, Duration timeTaken - ) { - this.courseId = courseId; - this.distance = distance; - this.stepOrder = stepOrder; - this.startLocation = startLocation; - this.endLocation = endLocation; - this.timeTaken = timeTaken; + private RouteStep(Long originSpotId, Long destinationSpotId, List detailPaths, + Double distanceKm, Integer timeTakenMin) { + this.originSpotId = originSpotId; + this.destinationSpotId = destinationSpotId; + this.detailPaths = detailPaths; + this.distanceKm = distanceKm; + this.timeTakenMin = timeTakenMin; } -} + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/AttractionSpot.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/AttractionSpot.java deleted file mode 100644 index 6cc8c33..0000000 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/AttractionSpot.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.example.mohago_nocar.course.domain.model.travelSpot; - -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import static lombok.AccessLevel.PROTECTED; - -@Entity -@Getter -@NoArgsConstructor(access = PROTECTED) -@DiscriminatorValue("ATTRACTION") -public class AttractionSpot extends TravelSpot { - - public static AttractionSpot from(Long courseId, Integer order) { - return AttractionSpot.builder() - .courseId(courseId) - .order(order) - .build(); - } - - @Builder - private AttractionSpot(Long courseId, Integer order) { - this.courseId = courseId; - this.spotOrder = order; - } -} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/RestaurantSpot.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/RestaurantSpot.java deleted file mode 100644 index 6ff3a70..0000000 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/RestaurantSpot.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.mohago_nocar.course.domain.model.travelSpot; - -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import lombok.*; - -import static lombok.AccessLevel.PROTECTED; - -@Entity -@Getter -@NoArgsConstructor(access = PROTECTED) -@DiscriminatorValue("RESTAURANT") -public class RestaurantSpot extends TravelSpot { - - public static RestaurantSpot from(Long courseId, Integer order) { - return RestaurantSpot.builder() - .courseId(courseId) - .order(order) - .build(); - } - - @Builder - private RestaurantSpot(Long courseId, Integer order) { - this.courseId = courseId; - this.spotOrder = order; - } -} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java index 6dbde77..39bd29b 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpot.java @@ -1,23 +1,60 @@ package com.example.mohago_nocar.course.domain.model.travelSpot; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.global.common.domain.BaseEntity; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.plan.domain.model.Location; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.*; import static jakarta.persistence.GenerationType.IDENTITY; @Entity +@Getter @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "spot_type") -public abstract class TravelSpot extends BaseEntity { +@Table(name = "travel_spot") +@ToString +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class TravelSpot extends BaseEntity implements Comparable { @Id @GeneratedValue(strategy = IDENTITY) - protected Long id; + private Long id; @NotNull - protected Long courseId; + private Long courseId; + + private Integer visitOrder; @NotNull - protected Integer spotOrder; -} + @Embedded + private Location location; // snapshot + + protected TravelSpot(Long courseId, Integer visitOrder, Location location) { + this.courseId = courseId; + this.visitOrder = visitOrder; + this.location = location; + } + + @Override + public int compareTo(TravelSpot other) { + Objects.requireNonNull(this.getVisitOrder(), "방문 순서가 정해지지 않은 장소입니다."); + Objects.requireNonNull(other.getVisitOrder(), "방문 순서가 정해지지 않은 장소입니다."); + + return Comparator.comparing(TravelSpot::getVisitOrder) + .compare(this, other); + } + + public void setOrder(int i) { + visitOrder = i; + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java new file mode 100644 index 0000000..c341adc --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotFestival.java @@ -0,0 +1,45 @@ +package com.example.mohago_nocar.course.domain.model.travelSpot; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.plan.domain.model.Location; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@DiscriminatorValue("festival") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelSpotFestival extends TravelSpot{ + + private Long festivalId; + + @Builder + private TravelSpotFestival(Long courseId, Integer visitOrder, Long festivalId, Location location){ + super(courseId, visitOrder, location); + this.festivalId = festivalId; + } + + public TravelSpotFestival create(Long courseId, Integer visitOrder, Long festivalId) { + return TravelSpotFestival.builder() + .courseId(courseId) + .visitOrder(visitOrder) + .festivalId(festivalId) + .build(); + } + + public static TravelSpotFestival createWithNoOrder( + TravelCourse course, Festival festival) { + return TravelSpotFestival.builder() + .courseId(course.getId()) + .visitOrder(null) + .festivalId(festival.getId()) + .location(Location.of(festival)) + .build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java new file mode 100644 index 0000000..4e81161 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/model/travelSpot/TravelSpotPlace.java @@ -0,0 +1,44 @@ +package com.example.mohago_nocar.course.domain.model.travelSpot; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.plan.domain.model.Location; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@DiscriminatorValue("place") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelSpotPlace extends TravelSpot{ + + private String placeId; + + @Builder + private TravelSpotPlace(Long courseId, Integer visitOrder, String placeId, Location location) { + super(courseId, visitOrder, location); + this.placeId = placeId; + } + + public TravelSpotPlace create(Long courseId, Integer visitOrder, String placeId) { + return TravelSpotPlace.builder() + .courseId(courseId) + .visitOrder(visitOrder) + .placeId(placeId) + .build(); + } + + public static TravelSpotPlace createWithNoOrder(TravelCourse course, Place place) { + return TravelSpotPlace.builder() + .courseId(course.getId()) + .visitOrder(null) + .placeId(place.getKakaoId()) + .location(Location.of(place)) + .build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/CourseRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/CourseRepository.java deleted file mode 100644 index e39ca83..0000000 --- a/src/main/java/com/example/mohago_nocar/course/domain/repository/CourseRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.mohago_nocar.course.domain.repository; - -public interface CourseRepository { -} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/RouteStepRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/RouteStepRepository.java index 4680784..2b52a69 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/repository/RouteStepRepository.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/RouteStepRepository.java @@ -1,4 +1,15 @@ package com.example.mohago_nocar.course.domain.repository; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + public interface RouteStepRepository { + List saveAll(List routeSteps); + + Optional findByOriginAndDestination(Long originSpotId, Long destinationSpotId); + + List findByIds(List ids); } diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseEventOutboxRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseEventOutboxRepository.java new file mode 100644 index 0000000..78409be --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseEventOutboxRepository.java @@ -0,0 +1,15 @@ +package com.example.mohago_nocar.course.domain.repository; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventOutbox; + +import java.util.List; + +public interface TravelCourseEventOutboxRepository { + + TravelCourseOptimizedEventOutbox save(TravelCourseOptimizedEventOutbox travelCourseOptimizedEventOutbox); + + List findByStatusInOrderByCreatedDateAsc( + List statuses, int size); + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseOptimizedEventHandleHistoryRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseOptimizedEventHandleHistoryRepository.java new file mode 100644 index 0000000..f076940 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseOptimizedEventHandleHistoryRepository.java @@ -0,0 +1,8 @@ +package com.example.mohago_nocar.course.domain.repository; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventHandleHistory; +import org.springframework.stereotype.Repository; + +public interface TravelCourseOptimizedEventHandleHistoryRepository { + TravelCourseOptimizedEventHandleHistory save(TravelCourseOptimizedEventHandleHistory handleHistory); +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java new file mode 100644 index 0000000..480626a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelCourseRepository.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.course.domain.repository; + +import com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface TravelCourseRepository { + + TravelCourse save(TravelCourse course); + + Optional findById(Long travelCourseId); + +} + diff --git a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelSpotRepository.java b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelSpotRepository.java index f6128f9..a9b607d 100644 --- a/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelSpotRepository.java +++ b/src/main/java/com/example/mohago_nocar/course/domain/repository/TravelSpotRepository.java @@ -1,4 +1,16 @@ package com.example.mohago_nocar.course.domain.repository; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; + +import java.util.Collection; +import java.util.List; + public interface TravelSpotRepository { + TravelSpot save(TravelSpot travelSpot); + + // todo 배치 insert 지원 시 Jpa 기본 saveAll 사용 + List saveAll(Collection travelSpots); + + List findByTravelCourseId(Long travelCourseId); + } diff --git a/src/main/java/com/example/mohago_nocar/course/domain/service/CourseUseCase.java b/src/main/java/com/example/mohago_nocar/course/domain/service/CourseUseCase.java deleted file mode 100644 index ce64044..0000000 --- a/src/main/java/com/example/mohago_nocar/course/domain/service/CourseUseCase.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.mohago_nocar.course.domain.service; - -public interface CourseUseCase { -} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java new file mode 100644 index 0000000..b09b98d --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelCourseUseCase.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.course.domain.service; + +import com.example.mohago_nocar.course.application.dto.RouteStepDto; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.presentation.dto.CreateTravelCourseRequestDto; +import com.example.mohago_nocar.course.presentation.dto.CreateOptimizedTravelCourseAcceptedResponseDto; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface TravelCourseUseCase { + + /** + * 여행 코스 내 장소 간 대중교통 이동 경로를 생성합니다. + * @param travelCourseId 장소 방문 순서가 결정된 여행 코스의 아이디 + */ + void generateTransitRoute(Long travelCourseId); + + /** + * 여행 장소들을 방문할 순서를 결정합니다. + * 모든 장소를 가장 빠르게 거칠 수 있는 최적화된 방문 순서를 제공합니다. + */ + CreateOptimizedTravelCourseAcceptedResponseDto createOptimizedTravelCourse(CreateTravelCourseRequestDto request); + + List getOptimizedTravelCourseRoutes(Long courseId, UUID ownerUserId); + + Optional findById(Long travelCourseId); + + void updateUncompletedCourseStatus(Long travelCourseId, TravelCourseStatus courseStatus); + +} diff --git a/src/main/java/com/example/mohago_nocar/course/domain/service/TravelSpotUseCase.java b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelSpotUseCase.java new file mode 100644 index 0000000..25a35a3 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/domain/service/TravelSpotUseCase.java @@ -0,0 +1,23 @@ +package com.example.mohago_nocar.course.domain.service; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.place.domain.model.Place; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +public interface TravelSpotUseCase { + + Set createUnOrderedTravelSpots(TravelCourse course, Festival festival, List places); + + Set determineOptimizedTravelOrder(Set unorderedSpots); + + List saveAll(Set spotsWithOrder); + + List getByCourseId(Long travelCourseId); + + Set makeSpotsWithoutOrder(TravelCourse ownerCourse, Long festivalId, LocalDate travelDate, List placeIds); +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseJpaRepository.java deleted file mode 100644 index ac8be15..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseJpaRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.course; - -import com.example.mohago_nocar.course.domain.model.course.Course; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CourseJpaRepository extends JpaRepository { -} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseRepositoryImpl.java deleted file mode 100644 index 7813709..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/CourseRepositoryImpl.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.course; - -import com.example.mohago_nocar.course.domain.repository.CourseRepository; -import com.example.mohago_nocar.course.infrastructure.course.CourseJpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public class CourseRepositoryImpl implements CourseRepository { - - private CourseJpaRepository courseJpaRepository; -} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageHandler.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageHandler.java new file mode 100644 index 0000000..0cac52a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageHandler.java @@ -0,0 +1,27 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.global.notification.application.developer.DeveloperNotificationUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.scheduling.annotation.Scheduled; + +import java.util.*; + +@RequiredArgsConstructor +@Slf4j +public class LongPendingMessageHandler { + + private final LongPendingMessageReader longPendingMessageReader; + private final DeveloperNotificationUseCase developerNotificationUseCase; + + private static final int FIXED_RATE_IN_MILS = 60_000; // 1분 + + @Scheduled(fixedRate = FIXED_RATE_IN_MILS) + public void process() { + List pendingMessages = longPendingMessageReader.read(); + developerNotificationUseCase.sendNotification( + "Long Pending 메시지가 " + pendingMessages.size() + "개 있습니다."); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageReader.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageReader.java new file mode 100644 index 0000000..cec7612 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/LongPendingMessageReader.java @@ -0,0 +1,39 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.global.util.RedisStreamHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.stream.PendingMessage; +import org.springframework.data.redis.connection.stream.PendingMessages; + +import java.util.List; + +@RequiredArgsConstructor +@Slf4j +public class LongPendingMessageReader { + + private final String streamKeyName; + private final String consumerGroupName; + private final String consumerName; + private final int maxScanNum; + private final long idleTimeThresholdMills; + + private final RedisStreamHelper redisStreamHelper; + + public List read() { + log.debug("Starting long pending message scan..."); + PendingMessages pendingMessages = redisStreamHelper.getPendingMessages( + streamKeyName, consumerGroupName, consumerName, maxScanNum, Range.unbounded()); + + if (pendingMessages.isEmpty()) { + log.debug("No pending messages found"); + return List.of(); + } + + return pendingMessages.stream() + .filter(msg -> msg.getElapsedTimeSinceLastDelivery().toMillis() > idleTimeThresholdMills) + .toList(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisher.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisher.java new file mode 100644 index 0000000..b41d3e0 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisher.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; + +public interface TravelCourseOptimizedEventPublisher { + + void publish(TravelCourseOptimizedEvent event); + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java new file mode 100644 index 0000000..a9f0b13 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageConsumer.java @@ -0,0 +1,139 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.course.application.course.TravelCourseEventHandler; +import com.example.mohago_nocar.course.domain.event.ThrottlingCompletedEvent; +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.global.messaging.DeadLetterQueueEntryDto; +import com.example.mohago_nocar.global.messaging.DeadLetterQueueService; +import com.example.mohago_nocar.global.util.RedisStreamHelper; +import com.example.mohago_nocar.global.common.RetryPolicy; +import com.example.mohago_nocar.global.util.Result; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.stream.StreamListener; + +import java.util.concurrent.*; + +@Slf4j +@Getter +@RequiredArgsConstructor +public class TravelCourseOptimizedMessageConsumer + implements StreamListener> { + + private final String streamName; + private final String consumerGroupName; + private final String consumerName; + + private final RedisStreamHelper redisStreamHelper; + private final DeadLetterQueueService dlqService; + private final ObjectMapper objectMapper; + private final RetryPolicy retryPolicy; + private final TravelCourseEventHandler eventHandler; + private final TravelCourseUseCase travelCourseUseCase; + + private ExecutorService executorService; + private Semaphore semaphore; + + @PostConstruct + public void init() { + executorService = Executors.newVirtualThreadPerTaskExecutor(); + semaphore = new Semaphore(0); + } + + @Override + public void onMessage(ObjectRecord message) { + log.info("Received message {}", message); + + executorService.submit(() -> { + processEvent(message); + }); + + try { + if (!semaphore.tryAcquire(1, TimeUnit.SECONDS)) { + log.info("idle 대기 시간을 넘겼습니다. 다음 메시지를 처리할 수 있도록 컨슈머 스레드의 블로킹을 해제합니다."); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private void processEvent(ObjectRecord message) { + TravelCourseOptimizedEvent event = null; + try { + event = objectMapper.readValue(message.getValue(), TravelCourseOptimizedEvent.class); + } catch (JsonProcessingException e) { + log.error("메시지 역직렬화 중 에러가 발생했습니다."); + saveToDeadLetterQueue(message, e); + ackAndDel(message); + return; + } + + if (eventHandler.hasHandleHistory(event.getTravelCourseId())) { + log.info("메시지 중복 처리 시도가 발생했습니다. 메시지 처리를 중단합니다."); + ackAndDel(message); + return; + } + + try { + eventHandler.handleEvent(event); + } catch (Exception ex) { + handleException(event, message, ex); + } finally { + ackAndDel(message); + } + } + + private void handleException(TravelCourseOptimizedEvent event, ObjectRecord message, Exception e) { + log.error(e.getMessage(), e); + if (retryPolicy.isRetryable(e)) { + saveToDeadLetterQueue(message, e); + return; + } + + eventHandler.processFailEvent(event); + } + + private void saveToDeadLetterQueue(ObjectRecord message, Exception ex) { + log.info("재시도 가능한 예외입니다. 재처리를 위해 메시지를 저장합니다."); + DeadLetterQueueEntryDto dto = DeadLetterQueueEntryDto.of( + message.getId().getValue(), streamName, getConsumerGroupName(), getConsumerName(), message.getValue(), ex); + dlqService.save(dto); + } + + private void ackAndDel(ObjectRecord message) { + redisStreamHelper.acknowledgeAndDelete(streamName, consumerGroupName, message.getId()); + } + + @EventListener + public void allowNextConsume(ThrottlingCompletedEvent event) { + log.info("travelCourse ID:{} throttling completed", event.getTravelCourseId()); + semaphore.release(); + } + + @PreDestroy + private void destroy() { + log.info("ExecutorService in TravelCourseOptimizedMessageConsumer shutdown 시작"); + executorService.shutdown(); + try { + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + log.warn("정상 종료에 실패했습니다. 강제 종료를 시작합니다."); + executorService.shutdownNow(); + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + log.error("강제 종료에 실패하였습니다."); + } + } + } catch (InterruptedException e) { + log.error("인터럽트 발생", e); + executorService.shutdownNow(); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageProducer.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageProducer.java new file mode 100644 index 0000000..63deb13 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageProducer.java @@ -0,0 +1,34 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +public class TravelCourseOptimizedMessageProducer implements TravelCourseOptimizedEventPublisher { + + private final String streamKey; + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapperUtil objectMapperUtil; + + @Override + public void publish(TravelCourseOptimizedEvent event) { + String eventStr = objectMapperUtil.writeValue(event); + + ObjectRecord record = StreamRecords + .newRecord() + .ofObject(eventStr) + .withStreamKey(streamKey); + + stringRedisTemplate.opsForStream().add(record); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageRetryPolicy.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageRetryPolicy.java new file mode 100644 index 0000000..2b06b36 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedMessageRetryPolicy.java @@ -0,0 +1,47 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.global.common.RetryPolicy; +import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.dao.TransientDataAccessException; + +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.http.HttpTimeoutException; +import java.util.concurrent.CompletionException; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelCourseOptimizedMessageRetryPolicy implements RetryPolicy { + + @Override + public boolean isRetryable(Throwable throwable) { + if (throwable instanceof SocketTimeoutException || + throwable instanceof ConnectException || + throwable instanceof HttpTimeoutException){ + return true; + } + + if (throwable instanceof TransientDataAccessException || + throwable instanceof DataAccessResourceFailureException){ + return true; + } + + if (throwable instanceof CompletionException exception) { + if (exception.getCause() instanceof ODsayRouteException odsayException) { + return odsayException.getErrorCode().isTooManyRequests() || + odsayException.getErrorCode().isServerError(); + } + } + + // todo Completion Exception 안에 OdsayRouteException이 있을 때 아래로 안잡히는지 테스트 + if (throwable instanceof ODsayRouteException exception){ + return exception.getErrorCode().isTooManyRequests() || + exception.getErrorCode().isServerError(); + } + + return false; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java new file mode 100644 index 0000000..49ea91f --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamConfig.java @@ -0,0 +1,108 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.course.application.course.TravelCourseEventHandler; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.global.messaging.DeadLetterQueueService; +import com.example.mohago_nocar.global.util.RedisStreamHelper; +import com.example.mohago_nocar.global.common.RetryPolicy; +import com.example.mohago_nocar.global.notification.application.developer.DeveloperNotificationUseCase; +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class TravelCourseOptimizedStreamConfig { + + @Value("${redis.streams.course.optimized.main}") + private String streamKey; + + private static final String CONSUMER_GROUP = "processors"; + private static final String CONSUMER_1 = "processor-1"; + private int messagesPerPolling = 3; + + @Bean + public TravelCourseOptimizedStreamContainer travelCourseOptimizedStreamContainer( + TravelCourseOptimizedMessageConsumer consumer, + TravelCourseOptimizedStreamRecoveryManager recoveryManager, + StringRedisTemplate stringRedisTemplate + ) { + return new TravelCourseOptimizedStreamContainer( + CONSUMER_GROUP, + CONSUMER_1, + messagesPerPolling, + consumer, + recoveryManager, + stringRedisTemplate + ); + } + + @Bean + public TravelCourseOptimizedStreamRecoveryManager travelCourseOptimizedStreamRecoveryManager( + StringRedisTemplate stringRedisTemplate, + TravelCourseOptimizedMessageConsumer consumer + ){ + return new TravelCourseOptimizedStreamRecoveryManager( + stringRedisTemplate, consumer + ); + } + + @Bean + public TravelCourseOptimizedMessageProducer travelCourseOptimizedEventPublisher( + StringRedisTemplate stringRedisTemplate, + ObjectMapperUtil objectMapperUtil + ) { + return new TravelCourseOptimizedMessageProducer( + streamKey, stringRedisTemplate, objectMapperUtil); + } + + @Bean + public TravelCourseOptimizedMessageConsumer travelCourseOptimizedMessageConsumer( + RedisStreamHelper redisStreamHelper, + ObjectMapper objectMapper, + DeadLetterQueueService deadLetterQueueService, + RetryPolicy travelSpotOptimizedStreamMsgRetryPolicy, + TravelCourseEventHandler travelCourseEventHandler, + TravelCourseUseCase travelCourseUseCase + ) { + return new TravelCourseOptimizedMessageConsumer( + streamKey, + CONSUMER_GROUP, + CONSUMER_1, + redisStreamHelper, + deadLetterQueueService, + objectMapper, + travelSpotOptimizedStreamMsgRetryPolicy, + travelCourseEventHandler, + travelCourseUseCase + ); + } + + @Bean + public TravelCourseOptimizedMessageRetryPolicy travelSpotOptimizedStreamMsgRetryPolicy() { + return new TravelCourseOptimizedMessageRetryPolicy(); + } + + @Bean + public LongPendingMessageReader travelSpotOptimizedLongPendingMessageReader(RedisStreamHelper redisStreamHelper) { + return new LongPendingMessageReader( + streamKey, CONSUMER_GROUP, CONSUMER_1, 200, 30_000, redisStreamHelper); + } + + @Bean + public LongPendingMessageHandler travelSpotOptimizedLongPendingMessageHandler( + LongPendingMessageReader travelSpotOptimizedLongPendingMessageReader, + DeveloperNotificationUseCase developerNotificationUseCase + ) { + return new LongPendingMessageHandler( + travelSpotOptimizedLongPendingMessageReader, + developerNotificationUseCase); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamContainer.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamContainer.java new file mode 100644 index 0000000..de8d394 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamContainer.java @@ -0,0 +1,115 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import io.lettuce.core.RedisBusyException; +import io.sentry.Sentry; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.SmartLifecycle; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; + +import java.time.Duration; +import java.util.Objects; + +@Slf4j +@RequiredArgsConstructor +public class TravelCourseOptimizedStreamContainer implements SmartLifecycle { + + @Value("${redis.streams.course.optimized.main}") + private String streamKey; + + private final String consumerGroupName; + private final String consumerName; + public final int messagesPerPolling; + + private final TravelCourseOptimizedMessageConsumer messageConsumer; + private final TravelCourseOptimizedStreamRecoveryManager recoveryManager; + private final StringRedisTemplate stringRedisTemplate; + + private StreamMessageListenerContainer> streamMessageListenerContainer; + + @PostConstruct + public void init() { + createStreamConsumerGroupIfNotExists(streamKey, consumerGroupName); + this.streamMessageListenerContainer = createContainer(); + } + + private void createStreamConsumerGroupIfNotExists(String streamKey, String consumerGroup) { + try { + stringRedisTemplate.opsForStream() + .createGroup(streamKey, ReadOffset.from("0"), consumerGroup); + log.info("Consumer group {} created", consumerGroup); + + } catch (RedisSystemException e) { + Throwable cause = e.getCause(); + + if (cause instanceof RedisBusyException busyException) { + log.info(busyException.getMessage()); + return; + } + + throw new RuntimeException( + "Unexpected error while creating consumer group: ", cause); + } + } + + private StreamMessageListenerContainer> createContainer() { + StreamMessageListenerContainer> listenerContainer = + StreamMessageListenerContainer.create( + Objects.requireNonNull(stringRedisTemplate.getConnectionFactory()), + StreamMessageListenerContainer + .StreamMessageListenerContainerOptions.builder() + .targetType(String.class) + .pollTimeout(Duration.ofSeconds(3)) + .batchSize(messagesPerPolling) + .errorHandler(throwable -> { + log.error("Error occurs during Redis stream message consuming", throwable); + Sentry.captureException(throwable); + }) + .build() + ); + + listenerContainer.register( + StreamMessageListenerContainer.StreamReadRequest + .builder(StreamOffset.create(streamKey, ReadOffset.lastConsumed())) + //turn off auto shutdown of stream consumer if an error occurs. + .cancelOnError(throwable -> false) + .consumer(Consumer.from( + messageConsumer.getConsumerGroupName(), + messageConsumer.getConsumerName())) + .autoAcknowledge(false).build() + , messageConsumer); + + return listenerContainer; + } + + @Override + public void start() { +// recoveryManager.recovery(streamKey, consumerGroupName, consumerName); + streamMessageListenerContainer.start(); + } + + @Override + public void stop() { + streamMessageListenerContainer.stop(); + } + + @Override + public boolean isRunning() { + return streamMessageListenerContainer.isRunning(); + } + + @Override + public int getPhase() { + return Integer.MAX_VALUE; + } + +} + + + + diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamRecoveryManager.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamRecoveryManager.java new file mode 100644 index 0000000..6dd12c9 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedStreamRecoveryManager.java @@ -0,0 +1,59 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.StreamOperations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +public class TravelCourseOptimizedStreamRecoveryManager { + + private final StringRedisTemplate stringRedisTemplate; + private final TravelCourseOptimizedMessageConsumer travelCourseOptimizedMessageConsumer; + + public void recovery(String streamKey, String consumerGroupName, String consumerName) { + StreamOperations streamOps = stringRedisTemplate.opsForStream(); + + // 아래 코드 실행 시 라이브러리 버그로 인한 오류 발생 +// PendingMessages pendingMessages = streamOps.pending(streamKey, Consumer.from(consumerGroupName, consumerName)); +// long totalPendingMessages = pendingMessages.size(); + + // 현재 단일 컨슈머이므로 그룹 내 모든 Pending Message 조회 + PendingMessagesSummary summary = streamOps.pending(streamKey, consumerGroupName); + long totalPendingMessages = summary.getTotalPendingMessages(); + log.info("Pending messages in consumer group [{}]: {}", consumerGroupName, totalPendingMessages); + + StreamReadOptions options = StreamReadOptions.empty() + .count(totalPendingMessages); + + Consumer consumer = Consumer.from(consumerGroupName, consumerName); + + List> records = streamOps.read( + consumer, + options, + StreamOffset.fromStart(streamKey)).stream() + .map(record -> { + RecordId id = record.getId(); + Map recordValue = record.getValue(); + return ObjectRecord.create(streamKey, recordValue.get("payload").toString()).withId(id); + }) + .toList(); + + log.info("Starting recovery process for pending messages"); + for (ObjectRecord record : records) { + try { + travelCourseOptimizedMessageConsumer.onMessage(record); + } catch (Exception e) { + log.error("Failed to reprocess pending message. RecordId={}", record.getId(), e); + } + + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseEventOutboxRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseEventOutboxRepositoryImpl.java new file mode 100644 index 0000000..3de0a85 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseEventOutboxRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventOutbox; +import com.example.mohago_nocar.course.domain.repository.TravelCourseEventOutboxRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class TravelCourseEventOutboxRepositoryImpl implements TravelCourseEventOutboxRepository { + + private final TravelCourseOptimizedEventOutboxJpaRepository jpaRepository; + + @Override + @Transactional + public TravelCourseOptimizedEventOutbox save(TravelCourseOptimizedEventOutbox travelCourseOptimizedEventOutbox) { + return jpaRepository.save(travelCourseOptimizedEventOutbox); + } + + @Override + public List findByStatusInOrderByCreatedDateAsc( + List statuses, int size) { + return jpaRepository.findTop10ByStatusInOrderByCreatedDateAsc(statuses, size); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java new file mode 100644 index 0000000..044e121 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseJpaRepository.java @@ -0,0 +1,11 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; + +public interface TravelCourseJpaRepository extends JpaRepository { + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryJpaRepository.java new file mode 100644 index 0000000..09c66e6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryJpaRepository.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventHandleHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TravelCourseOptimizedEventHandleHistoryJpaRepository extends + JpaRepository { + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryRepositoryImpl.java new file mode 100644 index 0000000..3209030 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventHandleHistoryRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventHandleHistory; +import com.example.mohago_nocar.course.domain.repository.TravelCourseOptimizedEventHandleHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@RequiredArgsConstructor +public class TravelCourseOptimizedEventHandleHistoryRepositoryImpl + implements TravelCourseOptimizedEventHandleHistoryRepository { + + private final TravelCourseOptimizedEventHandleHistoryJpaRepository jpaRepository; + + @Override + @Transactional + public TravelCourseOptimizedEventHandleHistory save(TravelCourseOptimizedEventHandleHistory handleHistory) { + return jpaRepository.save(handleHistory); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventOutboxJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventOutboxJpaRepository.java new file mode 100644 index 0000000..0b681d9 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseOptimizedEventOutboxJpaRepository.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseOptimizedEventOutbox; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface TravelCourseOptimizedEventOutboxJpaRepository extends JpaRepository { + + @Query("SELECT o FROM TravelCourseOptimizedEventOutbox o " + + "WHERE o.status IN :statuses " + + "ORDER BY o.createdAt ASC " + + "LIMIT :size") + List findTop10ByStatusInOrderByCreatedDateAsc( + List statuses, int size); + + @Modifying(clearAutomatically = true) + @Query("update TravelCourseOptimizedEventOutbox o " + + "set o.status = :targetStatus " + + "where o.id in :ids") + int updateStatuses(List ids, OutboxStatus targetStatus); + + @Modifying(clearAutomatically = true) + @Query("update TravelCourseOptimizedEventOutbox o " + + "set o.status = :targetStatus " + + "where o.id = :id") + int updateStatus(Long id, OutboxStatus targetStatus); + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java new file mode 100644 index 0000000..0b461fe --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/course/repository/TravelCourseRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.example.mohago_nocar.course.infrastructure.course.repository; + +import com.example.mohago_nocar.course.application.dto.GetRequesterInfoDto; +import com.example.mohago_nocar.course.domain.model.course.TravelCourseStatus; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.repository.TravelCourseRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class TravelCourseRepositoryImpl implements TravelCourseRepository { + + private final TravelCourseJpaRepository travelCourseJpaRepository; + + @Override + public TravelCourse save(TravelCourse course) { + return travelCourseJpaRepository.save(course); + } + + @Override + public Optional findById(Long travelCourseId) { + return travelCourseJpaRepository.findById(travelCourseId); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepJpaRepository.java similarity index 52% rename from src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepJpaRepository.java rename to src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepJpaRepository.java index 7a6190e..cac562a 100644 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepJpaRepository.java +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepJpaRepository.java @@ -1,7 +1,12 @@ -package com.example.mohago_nocar.course.infrastructure.routeStep; +package com.example.mohago_nocar.course.infrastructure.route; import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface RouteStepJpaRepository extends JpaRepository { + + Optional findByOriginSpotIdAndDestinationSpotId(Long originSpotId, Long destinationSpotId); + } diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepRepositoryImpl.java new file mode 100644 index 0000000..f1a2f07 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/route/RouteStepRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.course.infrastructure.route; + +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.repository.RouteStepRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class RouteStepRepositoryImpl implements RouteStepRepository { + + private final RouteStepJpaRepository routeStepJpaRepository; + + @Override + public List saveAll(List routeSteps) { + return routeStepJpaRepository.saveAll(routeSteps); + } + + @Override + public Optional findByOriginAndDestination(Long originSpotId, Long destinationSpotId) { + return routeStepJpaRepository.findByOriginSpotIdAndDestinationSpotId(originSpotId, destinationSpotId); + } + + @Override + public List findByIds(List ids) { + return routeStepJpaRepository.findAllById(ids); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepRepositoryImpl.java deleted file mode 100644 index 7810897..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/routeStep/RouteStepRepositoryImpl.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.routeStep; - -import com.example.mohago_nocar.course.domain.repository.RouteStepRepository; -import com.example.mohago_nocar.course.infrastructure.routeStep.RouteStepJpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public class RouteStepRepositoryImpl implements RouteStepRepository { - - private RouteStepJpaRepository routeStepJpaRepository; -} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotJpaRepository.java new file mode 100644 index 0000000..cae6ce1 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotJpaRepository.java @@ -0,0 +1,11 @@ +package com.example.mohago_nocar.course.infrastructure.spot; + +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TravelSpotJpaRepository extends JpaRepository { + List findByCourseId(@NotNull Long courseId); +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotRepositoryImpl.java new file mode 100644 index 0000000..cde8c78 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/infrastructure/spot/TravelSpotRepositoryImpl.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.course.infrastructure.spot; + +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.course.domain.repository.TravelSpotRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class TravelSpotRepositoryImpl implements TravelSpotRepository { + + private final TravelSpotJpaRepository travelSpotJpaRepository; + + @Override + public TravelSpot save(TravelSpot travelSpot) { + return travelSpotJpaRepository.save(travelSpot); + } + + // todo 배치 insert 지원 시 Jpa 기본 saveAll 사용 + @Override + public List saveAll(Collection travelSpots) { + return travelSpotJpaRepository.saveAll(travelSpots); + } + + @Override + public List findByTravelCourseId(Long travelCourseId) { + return travelSpotJpaRepository.findByCourseId(travelCourseId); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotJpaRepository.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotJpaRepository.java deleted file mode 100644 index 5f440c0..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotJpaRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.travelSpot; - -import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface TravelSpotJpaRepository extends JpaRepository { -} diff --git a/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotRepositoryImpl.java deleted file mode 100644 index 78272c8..0000000 --- a/src/main/java/com/example/mohago_nocar/course/infrastructure/travelSpot/TravelSpotRepositoryImpl.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.course.infrastructure.travelSpot; - -import com.example.mohago_nocar.course.domain.repository.TravelSpotRepository; -import com.example.mohago_nocar.course.infrastructure.travelSpot.TravelSpotJpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public class TravelSpotRepositoryImpl implements TravelSpotRepository { - - private TravelSpotJpaRepository travelSpotJpaRepository; -} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/CourseController.java b/src/main/java/com/example/mohago_nocar/course/presentation/CourseController.java deleted file mode 100644 index fe68e51..0000000 --- a/src/main/java/com/example/mohago_nocar/course/presentation/CourseController.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.mohago_nocar.course.presentation; - -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -public class CourseController { -} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/TravelCourseController.java b/src/main/java/com/example/mohago_nocar/course/presentation/TravelCourseController.java new file mode 100644 index 0000000..153af4c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/presentation/TravelCourseController.java @@ -0,0 +1,40 @@ +package com.example.mohago_nocar.course.presentation; + +import com.example.mohago_nocar.course.application.dto.RouteStepDto; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.course.presentation.dto.CreateTravelCourseRequestDto; +import com.example.mohago_nocar.course.presentation.dto.CreateOptimizedTravelCourseAcceptedResponseDto; +import com.example.mohago_nocar.course.presentation.dto.GetOptimizedTravelCourseResponseDto; +import com.example.mohago_nocar.global.common.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/travel/courses") +public class TravelCourseController { + + private final TravelCourseUseCase travelCourseUseCase; + + @PostMapping("/") + public ApiResponse createOptimizedTravelCourseWithRoute( + @RequestBody @Valid CreateTravelCourseRequestDto request + ) { + CreateOptimizedTravelCourseAcceptedResponseDto response = travelCourseUseCase.createOptimizedTravelCourse(request); + return ApiResponse.ok(response); + } + + @GetMapping("/{courseId}") + public ApiResponse getOptimizedTravelCourseWithRoutes( + @PathVariable Long courseId, + @RequestParam UUID ownerUserId + ) { + List course = travelCourseUseCase.getOptimizedTravelCourseRoutes(courseId, ownerUserId); + return ApiResponse.ok(GetOptimizedTravelCourseResponseDto.of(course)); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateOptimizedTravelCourseAcceptedResponseDto.java b/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateOptimizedTravelCourseAcceptedResponseDto.java new file mode 100644 index 0000000..08cf341 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateOptimizedTravelCourseAcceptedResponseDto.java @@ -0,0 +1,14 @@ +package com.example.mohago_nocar.course.presentation.dto; + +import java.util.UUID; + +public record CreateOptimizedTravelCourseAcceptedResponseDto( + Long courseId, + UUID anonymousUserId +) { + + public static CreateOptimizedTravelCourseAcceptedResponseDto of(Long courseId, UUID anonymousUserId){ + return new CreateOptimizedTravelCourseAcceptedResponseDto(courseId, anonymousUserId); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateTravelCourseRequestDto.java b/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateTravelCourseRequestDto.java new file mode 100644 index 0000000..1205d05 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/presentation/dto/CreateTravelCourseRequestDto.java @@ -0,0 +1,26 @@ +package com.example.mohago_nocar.course.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; +import java.util.List; + +// todo 검증 추가 +public record CreateTravelCourseRequestDto( + + @Schema(description = "FCM 토큰") + String fcmToken, + + @Schema(description = "축제 아이디") + Long festivalId, + + // todo 검증 어노테이션 추가 + @Size(max = 4) + @Schema(description = "선택된 여행 장소들의 아이디로, 최소 2개-최대 4개 선택 가능", example = "[1234]") + List placeIds, + + @Schema(description = "여행 시작 날짜") + LocalDate travelStartDate +) { +} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseRequestDto.java b/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseRequestDto.java new file mode 100644 index 0000000..206f6d4 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseRequestDto.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.course.presentation.dto; + +import jakarta.validation.constraints.NotBlank; + +import java.util.UUID; + +public record GetOptimizedTravelCourseRequestDto( + @NotBlank + UUID anonymousUserId, + @NotBlank + Long travelCourseId +) { +} diff --git a/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseResponseDto.java b/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseResponseDto.java new file mode 100644 index 0000000..dd4f35c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/course/presentation/dto/GetOptimizedTravelCourseResponseDto.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.course.presentation.dto; + +import com.example.mohago_nocar.course.application.dto.RouteStepDto; + +import java.util.List; + +public record GetOptimizedTravelCourseResponseDto( + List routeSteps +) { + public static GetOptimizedTravelCourseResponseDto of(List stepDtos) { + return new GetOptimizedTravelCourseResponseDto(stepDtos); + } +} diff --git a/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java b/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java index 4b94e95..12a40ee 100644 --- a/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java +++ b/src/main/java/com/example/mohago_nocar/festival/application/FestivalService.java @@ -5,10 +5,9 @@ import com.example.mohago_nocar.festival.domain.repository.FestivalRepository; import com.example.mohago_nocar.festival.domain.service.FestivalImageUseCase; import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; -import com.example.mohago_nocar.festival.presentation.exception.FestivalNotFoundException; -import com.example.mohago_nocar.festival.presentation.response.FestivalActivePeriodResponseDto; -import com.example.mohago_nocar.festival.presentation.response.FestivalLocationResponseDto; -import com.example.mohago_nocar.festival.presentation.response.FestivalResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalActivePeriodResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalLocationResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalResponseDto; import com.example.mohago_nocar.global.common.dto.PagedResponseDto; import java.util.List; @@ -42,7 +41,7 @@ public PagedResponseDto fetchFestivals(Pageable pageable) { public FestivalLocationResponseDto getFestivalLocation(Long festivalId) { Festival festival = festivalRepository.getFestivalById(festivalId); - return FestivalLocationResponseDto.of(festival.getLocation()); + return FestivalLocationResponseDto.of(festival.getCoordinate()); } @Override diff --git a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalActivePeriodResponseDto.java b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalActivePeriodResponseDto.java similarity index 88% rename from src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalActivePeriodResponseDto.java rename to src/main/java/com/example/mohago_nocar/festival/application/response/FestivalActivePeriodResponseDto.java index ac613f7..8a6f749 100644 --- a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalActivePeriodResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalActivePeriodResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.festival.presentation.response; +package com.example.mohago_nocar.festival.application.response; import com.example.mohago_nocar.festival.domain.model.vo.ActivePeriod; import lombok.Builder; diff --git a/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalLocationResponseDto.java b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalLocationResponseDto.java new file mode 100644 index 0000000..4c78aae --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalLocationResponseDto.java @@ -0,0 +1,15 @@ +package com.example.mohago_nocar.festival.application.response; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import lombok.Builder; + +@Builder +public record FestivalLocationResponseDto( + Coordinate coordinate +) { + public static FestivalLocationResponseDto of(Coordinate coordinate) { + return new FestivalLocationResponseDtoBuilder() + .coordinate(coordinate) + .build(); + } +} diff --git a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalResponseDto.java b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalResponseDto.java similarity index 79% rename from src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalResponseDto.java rename to src/main/java/com/example/mohago_nocar/festival/application/response/FestivalResponseDto.java index c30d6b7..8643f47 100644 --- a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/festival/application/response/FestivalResponseDto.java @@ -1,10 +1,10 @@ -package com.example.mohago_nocar.festival.presentation.response; +package com.example.mohago_nocar.festival.application.response; import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.festival.domain.model.vo.ActivePeriod; import java.util.List; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import lombok.Builder; @Builder @@ -14,7 +14,7 @@ public record FestivalResponseDto( ActivePeriod activePeriod, String description, String address, - Location location, + Coordinate coordinate, List imageUrlList ) { @@ -25,7 +25,7 @@ public static FestivalResponseDto of(Festival festival, List imageUrlLis .activePeriod(festival.getActivePeriod()) .description(festival.getDescription()) .address(festival.getAddress()) - .location(festival.getLocation()) + .coordinate(festival.getCoordinate()) .imageUrlList(imageUrlList) .build(); } diff --git a/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java b/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java index f15a337..672e4e3 100644 --- a/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java +++ b/src/main/java/com/example/mohago_nocar/festival/domain/model/Festival.java @@ -2,7 +2,7 @@ import com.example.mohago_nocar.festival.domain.model.vo.ActivePeriod; import com.example.mohago_nocar.global.common.domain.BaseEntity; -import com.example.mohago_nocar.global.common.domain.vo.Location; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.*; @@ -14,6 +14,7 @@ @Entity @Getter +@Table(name = "festival") @NoArgsConstructor(access = PROTECTED) public class Festival extends BaseEntity { @@ -37,29 +38,34 @@ public class Festival extends BaseEntity { @NotNull @Embedded - private Location location; + private Coordinate coordinate; - public static Festival from(String name, ActivePeriod activePeriod, String description, String address, Location location) { + public static Festival from(String name, ActivePeriod activePeriod, String description, String address, Coordinate coordinate) { return Festival.builder() .name(name) .activePeriod(activePeriod) .description(description) .address(address) - .location(location) + .coordinate(coordinate) .build(); } @Builder - private Festival(String name, ActivePeriod activePeriod, String description, String address, Location location) { + private Festival(String name, ActivePeriod activePeriod, String description, String address, Coordinate coordinate) { this.name = name; this.activePeriod = activePeriod; this.description = description; this.address = address; - this.location = location; + this.coordinate = coordinate; } public boolean isDateDuringFestival(LocalDate travelDate) { return activePeriod.containsDate(travelDate); } + + public boolean isOpen(LocalDate travelDate) { + return activePeriod.containsDate(travelDate); + } + } diff --git a/src/main/java/com/example/mohago_nocar/festival/domain/model/FestivalImage.java b/src/main/java/com/example/mohago_nocar/festival/domain/model/FestivalImage.java index 3acd6bb..e153520 100644 --- a/src/main/java/com/example/mohago_nocar/festival/domain/model/FestivalImage.java +++ b/src/main/java/com/example/mohago_nocar/festival/domain/model/FestivalImage.java @@ -4,6 +4,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; +import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; @@ -17,6 +18,7 @@ @Entity @Getter @Builder +@Table(name = "festival_image") @AllArgsConstructor(access = PRIVATE) @NoArgsConstructor(access = PROTECTED) public class FestivalImage extends BaseEntity { diff --git a/src/main/java/com/example/mohago_nocar/festival/domain/service/FestivalUseCase.java b/src/main/java/com/example/mohago_nocar/festival/domain/service/FestivalUseCase.java index a76fc7b..c7455e7 100644 --- a/src/main/java/com/example/mohago_nocar/festival/domain/service/FestivalUseCase.java +++ b/src/main/java/com/example/mohago_nocar/festival/domain/service/FestivalUseCase.java @@ -1,9 +1,9 @@ package com.example.mohago_nocar.festival.domain.service; import com.example.mohago_nocar.festival.domain.model.Festival; -import com.example.mohago_nocar.festival.presentation.response.FestivalActivePeriodResponseDto; -import com.example.mohago_nocar.festival.presentation.response.FestivalLocationResponseDto; -import com.example.mohago_nocar.festival.presentation.response.FestivalResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalActivePeriodResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalLocationResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalResponseDto; import com.example.mohago_nocar.global.common.dto.PagedResponseDto; import java.util.List; diff --git a/src/main/java/com/example/mohago_nocar/festival/presentation/FestivalController.java b/src/main/java/com/example/mohago_nocar/festival/presentation/FestivalController.java index 78b6bbc..a5b3db2 100644 --- a/src/main/java/com/example/mohago_nocar/festival/presentation/FestivalController.java +++ b/src/main/java/com/example/mohago_nocar/festival/presentation/FestivalController.java @@ -1,8 +1,8 @@ package com.example.mohago_nocar.festival.presentation; import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; -import com.example.mohago_nocar.festival.presentation.response.FestivalActivePeriodResponseDto; -import com.example.mohago_nocar.festival.presentation.response.FestivalResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalActivePeriodResponseDto; +import com.example.mohago_nocar.festival.application.response.FestivalResponseDto; import com.example.mohago_nocar.global.common.dto.PagedResponseDto; import com.example.mohago_nocar.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; diff --git a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalLocationResponseDto.java b/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalLocationResponseDto.java deleted file mode 100644 index 6aa7c38..0000000 --- a/src/main/java/com/example/mohago_nocar/festival/presentation/response/FestivalLocationResponseDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.mohago_nocar.festival.presentation.response; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import lombok.Builder; - -@Builder -public record FestivalLocationResponseDto( - Location location -) { - public static FestivalLocationResponseDto of(Location location) { - return new FestivalLocationResponseDtoBuilder() - .location(location) - .build(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/global/common/RetryPolicy.java b/src/main/java/com/example/mohago_nocar/global/common/RetryPolicy.java new file mode 100644 index 0000000..9ab657c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/common/RetryPolicy.java @@ -0,0 +1,7 @@ +package com.example.mohago_nocar.global.common; + +public interface RetryPolicy { + + boolean isRetryable(Throwable throwable); + +} diff --git a/src/main/java/com/example/mohago_nocar/global/common/domain/BaseEntity.java b/src/main/java/com/example/mohago_nocar/global/common/domain/BaseEntity.java index d7eced5..f9d3302 100644 --- a/src/main/java/com/example/mohago_nocar/global/common/domain/BaseEntity.java +++ b/src/main/java/com/example/mohago_nocar/global/common/domain/BaseEntity.java @@ -21,4 +21,9 @@ public abstract class BaseEntity { @LastModifiedDate private LocalDateTime updatedAt; + + public void setUpdatedAt(LocalDateTime newUpdatedAt) { + this.updatedAt = newUpdatedAt; + } + } diff --git a/src/main/java/com/example/mohago_nocar/global/common/domain/OutboxStatus.java b/src/main/java/com/example/mohago_nocar/global/common/domain/OutboxStatus.java new file mode 100644 index 0000000..76ce404 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/common/domain/OutboxStatus.java @@ -0,0 +1,7 @@ +package com.example.mohago_nocar.global.common.domain; + +public enum OutboxStatus { + PENDING, // 아직 처리 안됨 + FAIL, // 처리 실패 + SENT // 처리 완료 +} diff --git a/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Location.java b/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Coordinate.java similarity index 50% rename from src/main/java/com/example/mohago_nocar/global/common/domain/vo/Location.java rename to src/main/java/com/example/mohago_nocar/global/common/domain/vo/Coordinate.java index 93e39d8..50bb403 100644 --- a/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Location.java +++ b/src/main/java/com/example/mohago_nocar/global/common/domain/vo/Coordinate.java @@ -6,18 +6,24 @@ @Embeddable @Builder @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor @AllArgsConstructor(access = AccessLevel.PRIVATE) @EqualsAndHashCode(of = {"longitude", "latitude"}) -public class Location { +@ToString +public class Coordinate { private Double longitude; // x private Double latitude; // y - public static Location from(Double longitude, Double latitude) { - return Location.builder() + public static Coordinate from(Double longitude, Double latitude) { + return com.example.mohago_nocar.global.common.domain.vo.Coordinate.builder() .longitude(longitude) .latitude(latitude) .build(); } + + public static Coordinate from(String longitude, String latitude) { + return com.example.mohago_nocar.global.common.domain.vo.Coordinate.from(Double.valueOf(longitude), Double.valueOf(latitude)); + } + } diff --git a/src/main/java/com/example/mohago_nocar/global/common/exception/CustomException.java b/src/main/java/com/example/mohago_nocar/global/common/exception/CustomException.java index e7db234..0a8d3b7 100644 --- a/src/main/java/com/example/mohago_nocar/global/common/exception/CustomException.java +++ b/src/main/java/com/example/mohago_nocar/global/common/exception/CustomException.java @@ -2,6 +2,7 @@ import lombok.Getter; +// todo: businessException으로 이름 변경 @Getter public class CustomException extends RuntimeException { diff --git a/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java b/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java new file mode 100644 index 0000000..4c06c64 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/EmbeddedRedisConfig.java @@ -0,0 +1,35 @@ +package com.example.mohago_nocar.global.config; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import redis.embedded.RedisServer; + +@Configuration +@Profile("test") +public class EmbeddedRedisConfig { + +/* @Value("${spring.data.redis.port") + private int port; + + private RedisServer redisServer; + + @PostConstruct + public void startEmbeddedRedis() { + redisServer = RedisServer.builder() + .port(port) + .setting("maxmemory 128M") + .build(); + redisServer.start(); + } + + @PreDestroy + public void stopEmbeddedRedis() { + if (redisServer != null) { + redisServer.stop(); + } + }*/ + +} diff --git a/src/main/java/com/example/mohago_nocar/global/config/EnableSchedulingConfig.java b/src/main/java/com/example/mohago_nocar/global/config/EnableSchedulingConfig.java new file mode 100644 index 0000000..2c6a37e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/EnableSchedulingConfig.java @@ -0,0 +1,10 @@ +package com.example.mohago_nocar.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class EnableSchedulingConfig { + +} diff --git a/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java b/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java new file mode 100644 index 0000000..3886166 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/ExecutorServiceConfig.java @@ -0,0 +1,19 @@ +package com.example.mohago_nocar.global.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; +import org.springframework.boot.autoconfigure.thread.Threading; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Configuration +public class ExecutorServiceConfig { + + @Bean + public ExecutorService virtualThreadExecutor(){ + return Executors.newVirtualThreadPerTaskExecutor(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/config/PropertyConfig.java b/src/main/java/com/example/mohago_nocar/global/config/PropertyConfig.java index 08fc2b4..44510d0 100644 --- a/src/main/java/com/example/mohago_nocar/global/config/PropertyConfig.java +++ b/src/main/java/com/example/mohago_nocar/global/config/PropertyConfig.java @@ -1,5 +1,6 @@ package com.example.mohago_nocar.global.config; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; import org.springframework.context.annotation.PropertySources; @@ -8,5 +9,6 @@ @PropertySources({ @PropertySource("classpath:env.properties") }) +@ConfigurationPropertiesScan(basePackages = {"com.example.mohago_nocar"}) public class PropertyConfig { } diff --git a/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java b/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java new file mode 100644 index 0000000..64d6bfa --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/RedisConfig.java @@ -0,0 +1,51 @@ +package com.example.mohago_nocar.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private String redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(); + redisStandaloneConfiguration.setHostName(redisHost); + redisStandaloneConfiguration.setPort(Integer.parseInt(redisPort)); + return new LettuceConnectionFactory(redisStandaloneConfiguration); + } + + @Bean + public RedisTemplate objectRedisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory()); + + + // key (string) + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // value (object) + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); + template.setValueSerializer(serializer); + template.setHashValueSerializer(serializer); + + template.afterPropertiesSet(); + + return template; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java b/src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java deleted file mode 100644 index f26c702..0000000 --- a/src/main/java/com/example/mohago_nocar/global/config/RestClientConfig.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.mohago_nocar.global.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.web.client.RestClient; - -import java.time.Duration; - -@Configuration -public class RestClientConfig { - - @Bean - public RestClient restClient() { - HttpComponentsClientHttpRequestFactory clientHttpRequestFactory = new HttpComponentsClientHttpRequestFactory(); - clientHttpRequestFactory.setConnectTimeout(Duration.ofSeconds(10)); - clientHttpRequestFactory.setConnectionRequestTimeout(Duration.ofSeconds(5)); - - return RestClient.builder() - .requestFactory(clientHttpRequestFactory) - .build(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/global/config/TimeZoneConfig.java b/src/main/java/com/example/mohago_nocar/global/config/TimeZoneConfig.java new file mode 100644 index 0000000..0064d52 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/config/TimeZoneConfig.java @@ -0,0 +1,16 @@ +package com.example.mohago_nocar.global.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Component; + +import java.util.TimeZone; + +@Component +public class TimeZoneConfig { + + @PostConstruct + public void configTimeZone() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DLQStatus.java b/src/main/java/com/example/mohago_nocar/global/messaging/DLQStatus.java new file mode 100644 index 0000000..c4e27fb --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DLQStatus.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.global.messaging; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DLQStatus { + + NEW("새로운 엔트리", "즉시 확인 필요"), + POSSESSING("처리 중", "원인 분석 및 해결 중"), + RESOLVED("해결 완료", "정상 처리 완료"), + IGNORED("무시", "처리 불필요로 판단"); + + private final String displayName; + private final String description; + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java new file mode 100644 index 0000000..fa72026 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntry.java @@ -0,0 +1,110 @@ +package com.example.mohago_nocar.global.messaging; + +import com.example.mohago_nocar.global.common.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.hibernate.annotations.Comment; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "dead_letter_queue") +@Getter +@EqualsAndHashCode +@ToString +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DeadLetterQueueEntry extends BaseEntity { + + @Id + @Column(nullable = false, length = 100) + @Comment("Stream entry ID") + private String entryId; + + @Column(nullable = false, length = 100) + @Comment("Redis Stream 키") + private String streamKey; + + @Column(nullable = false, length = 100) + @Comment("컨슈머 그룹 이름") + private String consumerGroup; + + @Column(columnDefinition = "TEXT") + @Comment("메시지 페이로드 (JSON)") + private String payload; + + @Comment("예외 타입") + private String exceptionType; + + @Column(columnDefinition = "TEXT") + @Comment("에러 메시지") + private String errorMessage; + + @Column(columnDefinition = "TEXT") + @Comment("스택 트레이스") + private String stackTrace; + + @Column(length = 100) + @Comment("마지막 처리 컨슈머") + private String lastConsumer; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 200) + @Comment("처리 상태") + private DLQStatus status; + + @Column + @Comment("해결 완료 시간") + private LocalDateTime resolvedAt; + + @Builder(access = AccessLevel.PRIVATE) + private DeadLetterQueueEntry(String streamKey, String consumerGroup, String entryId, + String payload, String exceptionType, String errorMessage, + String stackTrace, String lastConsumer, + DLQStatus status, LocalDateTime resolvedAt) { + this.streamKey = streamKey; + this.consumerGroup = consumerGroup; + this.entryId = entryId; + this.payload = payload; + this.exceptionType = exceptionType; + this.errorMessage = errorMessage; + this.stackTrace = stackTrace; + this.lastConsumer = lastConsumer; + this.status = status; + this.resolvedAt = resolvedAt; + } + + public static DeadLetterQueueEntry create(DeadLetterQueueEntryDto dto) { + DeadLetterQueueEntryBuilder builder = DeadLetterQueueEntry.builder() + .streamKey(dto.getStreamName()) + .consumerGroup(dto.getGroupName()) + .entryId(dto.getId()) + .payload(dto.getPayload()) + .lastConsumer(dto.getConsumerName()) + .status(DLQStatus.NEW); + + if (dto.getThrowable() != null) { + Throwable throwable = dto.getThrowable(); + return builder.errorMessage(throwable.getMessage()) + .exceptionType(throwable.getClass().getSimpleName()) + .stackTrace(ExceptionUtils.getStackTrace(throwable)) + .build(); + } else { + return builder.build(); + } + + } + + public void changeStatus(DLQStatus newStatus) { + this.status = newStatus; + + if (newStatus == DLQStatus.RESOLVED) { + this.resolvedAt = LocalDateTime.now(); + } + } + + public void syncUpdateTime(LocalDateTime now) { + this.setUpdatedAt(now); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryDto.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryDto.java new file mode 100644 index 0000000..b1958bc --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryDto.java @@ -0,0 +1,48 @@ +package com.example.mohago_nocar.global.messaging; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.data.redis.connection.stream.PendingMessage; + +@Getter +@ToString +public class DeadLetterQueueEntryDto { + + private String id; + private String streamName; + private String groupName; + private String consumerName; + private String payload; + private Throwable throwable; + + public static DeadLetterQueueEntryDto of( + String id, String streamName, String groupName, String consumerName, String payload, Throwable throwable) { + return new DeadLetterQueueEntryDto(id, streamName, groupName, consumerName, payload, throwable); + } + + public static DeadLetterQueueEntryDto from(String streamName, PendingMessage pendingMessage, String payload) { + return new DeadLetterQueueEntryDto( + pendingMessage.getId().toString(), + streamName, + pendingMessage.getGroupName(), + pendingMessage.getConsumerName(), + payload + ,null + ); + } + + @Builder(access = AccessLevel.PRIVATE) + private DeadLetterQueueEntryDto( + String id, String streamName, String groupName, + String consumerName, String payload, Throwable throwable) { + this.id = id; + this.streamName = streamName; + this.groupName = groupName; + this.consumerName = consumerName; + this.payload = payload; + this.throwable = throwable; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryRepository.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryRepository.java new file mode 100644 index 0000000..bf691eb --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryRepository.java @@ -0,0 +1,47 @@ +package com.example.mohago_nocar.global.messaging; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface DeadLetterQueueEntryRepository extends JpaRepository { + + List findByStatus(DLQStatus status); + + long countByStatus(DLQStatus status); + + /** + * 예외 타입별 통계 + */ + @Query("SELECT d.exceptionType, COUNT(d) FROM DeadLetterQueueEntry d " + + "GROUP BY d.exceptionType ORDER BY COUNT(d) DESC") + List countByExceptionType(); + + /** + * 특정 Stream의 엔트리 조회 + */ + List findByStreamKeyAndStatus( + String streamKey, + DLQStatus status + ); + + /** + * 오래된 NEW 상태 엔트리 (알림용) + */ + @Query("SELECT d FROM DeadLetterQueueEntry d " + + "WHERE d.status = 'NEW' " + + "AND d.createdAt < :threshold " + + "ORDER BY d.createdAt ASC") + List findOldNewEntries(@Param("threshold") LocalDateTime threshold); + + Optional findByEntryId(String entryId); + + List findAllByEntryIdIn(List entryIds); + +} diff --git a/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java new file mode 100644 index 0000000..02bf18f --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueService.java @@ -0,0 +1,91 @@ +package com.example.mohago_nocar.global.messaging; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DeadLetterQueueService { + + private final DeadLetterQueueEntryRepository dlqRepository; + + @Transactional + public void save(DeadLetterQueueEntryDto dto) { + log.debug("Dead Letter를 저장합니다. DeadLetterQueueEntryDto: {}", dto); + dlqRepository.save(DeadLetterQueueEntry.create(dto)); + } + + /** + * 주어진 DTO 리스트를 기반으로 {@link DeadLetterQueueEntry} 목록을 저장합니다. + * + *

저장 동작은 다음 기준에 따라 처리됩니다:

+ *
    + *
  • 동일한 {@code recordId}의 엔트리가 이미 존재하는 경우 + * → 내용이 동일하면 업데이트 시각만 갱신하고, 다르면 새로운 엔트리를 추가로 저장합니다. + *
  • + *
  • {@code recordId}가 존재하지 않는 경우 + * → 새로운 엔트리를 생성하여 저장합니다. + *
  • + *
+ * + * @param newDeadEntries 재시도 불가능한 예외로 인해 처리되지 못한 엔트리를 나타내는 DTO 리스트 + * @return 저장된 {@link DeadLetterQueueEntry} 리스트 + */ + @Transactional + public List saveAll(List newDeadEntries) { + List entryIds = newDeadEntries.stream() + .map(DeadLetterQueueEntryDto::getId) + .toList(); + + Map existedMap = dlqRepository.findAllByEntryIdIn(entryIds).stream() + .collect(Collectors.toMap( + DeadLetterQueueEntry::getEntryId, + Function.identity() + )); + + List entries = newDeadEntries.stream() + .map(DeadLetterQueueEntry::create) + .map(newEntry -> { + String entryId = newEntry.getEntryId(); + DeadLetterQueueEntry existingEntry = existedMap.get(entryId); + if (existingEntry != null) { + existingEntry.syncUpdateTime(LocalDateTime.now()); + return existingEntry; + }else { + return newEntry; + } + }).toList(); + + log.info("Saving dead letter queue entries"); + log.info("Dead letter queue entries: {}", entries); + + return dlqRepository.saveAll(entries); + } + + @Transactional(readOnly = true) + public List findByStatus(DLQStatus status) { + return dlqRepository.findByStatus(status); + } + + @Transactional + public void changeStatus(Long entryId, DLQStatus newStatus) { + DeadLetterQueueEntry entry = dlqRepository.findById(entryId) + .orElseThrow(() -> new IllegalArgumentException("DLQ entry not found: " + entryId)); + + entry.changeStatus(newStatus); + dlqRepository.save(entry); + + log.info("DLQ entry status changed: id={}, status={}", + entryId, newStatus); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/NotificationMessagingException.java b/src/main/java/com/example/mohago_nocar/global/notification/NotificationMessagingException.java new file mode 100644 index 0000000..8e95aa8 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/NotificationMessagingException.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.global.notification; + +public class NotificationMessagingException extends RuntimeException { + + public NotificationMessagingException(String message) { + super(message); + } + + public NotificationMessagingException(String message, Exception exception) { + super(message, exception); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCase.java b/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCase.java new file mode 100644 index 0000000..6677d8b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCase.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.global.notification.application.developer; + +public interface DeveloperNotificationUseCase { + + void sendNotification(String content); + + void sendNotification(String contentTitle, Throwable throwable); + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCaseImpl.java b/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCaseImpl.java new file mode 100644 index 0000000..a806e8a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/developer/DeveloperNotificationUseCaseImpl.java @@ -0,0 +1,50 @@ +package com.example.mohago_nocar.global.notification.application.developer; + +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.example.mohago_nocar.global.notification.infrastructure.discord.DiscordMessage; +import com.example.mohago_nocar.global.notification.infrastructure.discord.DiscordMessageSender; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class DeveloperNotificationUseCaseImpl implements DeveloperNotificationUseCase { + + private final DiscordMessageSender discordMessageSender; + private final ObjectMapperUtil objectMapperUtil; + + @Override + public void sendNotification(String content) { + discordMessageSender.send(DiscordMessage.of(content)); + } + + @Override + public void sendNotification(String contentTitle, Throwable throwable) { + String content = makeSummaryContentInJson(contentTitle, throwable); + DiscordMessage discordMessage = DiscordMessage.of(content); + discordMessageSender.send(discordMessage); + } + + private String makeSummaryContentInJson(String msgTitle, Throwable throwable) { + String errorMessage = throwable.getMessage(); + + // 스택 트레이스 상위 5줄 추출 + StackTraceElement[] stackTrace = throwable.getStackTrace(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < Math.min(5, stackTrace.length); i++) { + sb.append(stackTrace[i].toString()).append("\n"); + } + + Map payloadMap = new HashMap<>(); + payloadMap.put("title", msgTitle); + payloadMap.put("errorMessage", errorMessage); + payloadMap.put("stackTrace", sb.toString()); + + String content = objectMapperUtil.writeValue(payloadMap); + return content; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationDto.java b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationDto.java new file mode 100644 index 0000000..bd678b7 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationDto.java @@ -0,0 +1,24 @@ +package com.example.mohago_nocar.global.notification.application.user; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; +import java.util.UUID; + +@Getter +@AllArgsConstructor +@Builder +public class UserNotificationDto { + + private String title; + private String body; + private UUID userId; + private Map customData; + + public static UserNotificationDto of(String title, String body, UUID userId, Map customData) { + return new UserNotificationDto(title, body, userId, customData); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxHandler.java b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxHandler.java new file mode 100644 index 0000000..08d1877 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxHandler.java @@ -0,0 +1,49 @@ +package com.example.mohago_nocar.global.notification.application.user; + +import com.example.mohago_nocar.global.notification.domain.UserNotificationMessageOutbox; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +@Component +@Slf4j +@RequiredArgsConstructor +public class UserNotificationOutboxHandler { + + private final UserNotificationOutboxService outboxService; + private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); + + @Scheduled(fixedDelay = 1000) + public void handle() { + long handleStartTime = System.nanoTime(); + + List unpublishedList = outboxService.findUnpublished(10); + + List> futures = new ArrayList<>(); + for (UserNotificationMessageOutbox unpublished : unpublishedList) { + CompletableFuture future = + CompletableFuture.runAsync(() -> { + outboxService.publish(unpublished); + outboxService.markAsPublished(unpublished); + }, executorService) + .exceptionally(throwable -> { + outboxService.processFailure(unpublished, throwable); + return null; + }); + futures.add(future); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + long handleEndTime = System.nanoTime(); + log.info("Handle completed in {} ms", TimeUnit.NANOSECONDS.toMillis(handleEndTime - handleStartTime)); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxService.java b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxService.java new file mode 100644 index 0000000..4f3816f --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/application/user/UserNotificationOutboxService.java @@ -0,0 +1,93 @@ +package com.example.mohago_nocar.global.notification.application.user; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.global.notification.NotificationMessagingException; +import com.example.mohago_nocar.global.notification.domain.UserNotificationMessageOutbox; +import com.example.mohago_nocar.global.notification.infrastructure.UserNotificationMessageOutboxJpaRepository; +import com.example.mohago_nocar.user.domain.AnonymousUser; +import com.example.mohago_nocar.user.domain.UserUseCase; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@Slf4j +@RequiredArgsConstructor +public class UserNotificationOutboxService { + + private final UserNotificationMessageOutboxJpaRepository outboxRepository; + private final UserUseCase userUseCase; + private final FirebaseMessaging firebaseMessaging; + + @Transactional + public void save(UserNotificationDto dto) { + UserNotificationMessageOutbox msg = UserNotificationMessageOutbox.from(dto); + outboxRepository.save(msg); + } + + @Transactional + public List findUnpublished(int size) { + return outboxRepository.findByStatusInOrderByCreatedDateAsc( + List.of(OutboxStatus.PENDING), size); + } + + public void publish(UserNotificationMessageOutbox messageOutbox) { + UUID userId = messageOutbox.getUserId(); + AnonymousUser user = userUseCase.findByIdOrThrow(userId); + Message message = convertToFcmMessage(messageOutbox, user); + try { + firebaseMessaging.send(message); + } catch (FirebaseMessagingException e) { + throw new NotificationMessagingException("FCM 메시지 전송 중 오류가 발생했습니다", e); + } + } + + private Message convertToFcmMessage(UserNotificationMessageOutbox target, AnonymousUser user) { + Notification notification = Notification.builder() + .setTitle(target.getTitle()) + .setBody(target.getBody()) + .build(); + + Message.Builder builder = Message.builder() + .setToken(user.getFcmToken()) + .setNotification(notification); + + for (Map.Entry entry : target.getCustomData().entrySet()) { + builder.putData(entry.getKey(), entry.getValue()); + } + + return builder.build(); + } + + @Transactional + public void processFailure(UserNotificationMessageOutbox messageOutbox, Throwable throwable) { + log.error("메시지 전송 시도 중 오류가 발생했습니다. ", throwable); + if (throwable instanceof NotificationMessagingException) { + if (messageOutbox.isFinalRetry()) { + messageOutbox.markFailWithReason(throwable); + log.warn("최대 재시도 횟수에 도달했습니다. outbox id: {}", messageOutbox.getId()); + } else { + messageOutbox.incrementRetryCount(); + } + } else { + messageOutbox.markFailWithReason(throwable); + } + outboxRepository.save(messageOutbox); + } + + @Transactional + public void markAsPublished(UserNotificationMessageOutbox messageOutbox) { + messageOutbox.markAsPublished(); + outboxRepository.save(messageOutbox); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/domain/UserNotificationMessageOutbox.java b/src/main/java/com/example/mohago_nocar/global/notification/domain/UserNotificationMessageOutbox.java new file mode 100644 index 0000000..2584bf6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/domain/UserNotificationMessageOutbox.java @@ -0,0 +1,114 @@ +package com.example.mohago_nocar.global.notification.domain; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.global.common.domain.BaseEntity; +import com.example.mohago_nocar.global.notification.application.user.UserNotificationDto; +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; + +import java.util.Map; +import java.util.UUID; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserNotificationMessageOutbox extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String body; + + @Column(nullable = false) + private UUID userId; + + @Type(JsonBinaryType.class) + @Column(columnDefinition = "jsonb", nullable = true) + private Map customData; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OutboxStatus status; + + @Column(nullable = false) + private Integer retryCount; + + @Column(nullable = true) + private String failReason; + + public static UserNotificationMessageOutbox of( + OutboxStatus status, + Integer retryCount, + String title, + String body, + UUID userId, + Map customData + ) { + return UserNotificationMessageOutbox.builder() + .status(status) + .retryCount(retryCount) + .title(title) + .body(body) + .userId(userId) + .customData(customData) + .build(); + } + + public static UserNotificationMessageOutbox from(UserNotificationDto dto) { + return UserNotificationMessageOutbox.of( + OutboxStatus.PENDING, + 0, + dto.getTitle(), + dto.getBody(), + dto.getUserId(), + dto.getCustomData() + ); + } + + @Builder + private UserNotificationMessageOutbox( + OutboxStatus status, + Integer retryCount, + String title, + String body, + UUID userId, + Map customData, + String failReason + ) { + this.status = status; + this.retryCount = retryCount; + this.title = title; + this.body = body; + this.userId = userId; + this.customData = customData; + this.failReason = failReason; + } + + public boolean isFinalRetry() { + return 3 <= retryCount; + } + + public void markAsPublished() { + this.status = OutboxStatus.SENT; + } + + public void markFailWithReason(Throwable throwable) { + this.status = OutboxStatus.FAIL; + this.failReason = throwable.getMessage(); + } + + public void incrementRetryCount() { + retryCount++; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/UserNotificationMessageOutboxJpaRepository.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/UserNotificationMessageOutboxJpaRepository.java new file mode 100644 index 0000000..db17df4 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/UserNotificationMessageOutboxJpaRepository.java @@ -0,0 +1,20 @@ +package com.example.mohago_nocar.global.notification.infrastructure; + +import com.example.mohago_nocar.global.common.domain.OutboxStatus; +import com.example.mohago_nocar.global.notification.domain.UserNotificationMessageOutbox; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface UserNotificationMessageOutboxJpaRepository extends JpaRepository { + + @Query(value = "select o from UserNotificationMessageOutbox o " + + "WHERE o.status IN :statuses " + + "ORDER BY o.createdAt ASC " + + "LIMIT :size ") + List findByStatusInOrderByCreatedDateAsc( + List statuses, int size + ); + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessage.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessage.java new file mode 100644 index 0000000..51a9ebf --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessage.java @@ -0,0 +1,20 @@ +package com.example.mohago_nocar.global.notification.infrastructure.discord; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DiscordMessage { + + private final String content; + + // 필요 시 embeds 필드 추가... + + public static DiscordMessage of(String content) { + return new DiscordMessage(content); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessageSender.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessageSender.java new file mode 100644 index 0000000..fea3337 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/discord/DiscordMessageSender.java @@ -0,0 +1,41 @@ +package com.example.mohago_nocar.global.notification.infrastructure.discord; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Component +public class DiscordMessageSender { + + @Value("${discord.webhook-url}") + private String webhookUrl; + private WebClient webClient; + + @PostConstruct + public void init() { + webClient = WebClient.builder().build(); + } + + public void send(DiscordMessage message) { + try { + webClient.post() + .uri(webhookUrl) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(message) + .retrieve() + .toBodilessEntity() + .block(); + + log.info("Discord 메시지 전송 성공: content={}", message.getContent()); + } catch (Exception e) { + log.error("Discord 메시지 전송 실패: {}", e.getMessage(), e); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmConfig.java b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmConfig.java new file mode 100644 index 0000000..3712b36 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/notification/infrastructure/fcm/FcmConfig.java @@ -0,0 +1,63 @@ +package com.example.mohago_nocar.global.notification.infrastructure.fcm; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.io.FileInputStream; +import java.util.concurrent.Callable; + +// todo Prod: 환경변수 GOOGLE_APPLICATION_CREDENTIALS 사용 --> 등록 필요 +@Component +@Slf4j +@RequiredArgsConstructor +public class FcmConfig { + + @Value("${google.firebase.key.path}") + private String firebaseKeyPath; + + private final Environment env; + + @Bean + public FirebaseMessaging firebaseMessaging() { + GoogleCredentials credentials = loadFromLocalFile(); + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(credentials) + .setConnectTimeout(10_000) + .setReadTimeout(30_000) + .build(); + + FirebaseApp.initializeApp(options); + + return FirebaseMessaging.getInstance(); + } + + private GoogleCredentials loadFromLocalFile() { + return getCredentials(() -> { + FileInputStream serviceAccount = new FileInputStream(firebaseKeyPath); + return GoogleCredentials.fromStream(serviceAccount); + }); + } + + private GoogleCredentials getCredentials( + Callable callable + ) { + try { + return callable.call(); + } catch (Exception e) { + log.error("GoogleCredentials 획득 중 문제가 발생했습니다."); + log.error("에러: {}", e.getMessage()); + log.error("프로파일: {}", (Object) env.getActiveProfiles()); + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/rateLimit/IntervalRateLimiter.java b/src/main/java/com/example/mohago_nocar/global/rateLimit/IntervalRateLimiter.java new file mode 100644 index 0000000..d31ae23 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/rateLimit/IntervalRateLimiter.java @@ -0,0 +1,50 @@ +package com.example.mohago_nocar.global.rateLimit; + +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import java.util.concurrent.locks.ReentrantLock; + +/** + * 연속된 작업 실행 사이의 최소 시간 간격을 보장합니다. + * 여러 스레드가 동시에 접근해도 thread-safe하게 rate limiting을 수행합니다. + */ +@Slf4j +public class IntervalRateLimiter implements RateLimiter { + + private long nextExecutionTimeNanos; + + private final long minIntervalNanos; + private final ReentrantLock entryLock; + + public IntervalRateLimiter(long minIntervalMillis) { + this.minIntervalNanos = TimeUnit.MILLISECONDS.toNanos(minIntervalMillis); + this.nextExecutionTimeNanos = System.nanoTime(); + this.entryLock = new ReentrantLock(); + } + + /** + * 다음 실행이 허용될 때까지 현재 스레드를 대기시킵니다. + * Rate limit을 초과하지 않도록 필요한 시간만큼 blocking합니다. + */ + @Override + public void throttle() { + entryLock.lock(); + try { + long now = System.nanoTime(); + long waitTimeNanos = Math.max(0, nextExecutionTimeNanos - now); + + if (waitTimeNanos > 0) { + LockSupport.parkNanos(waitTimeNanos); + } + + this.nextExecutionTimeNanos = System.nanoTime() + minIntervalNanos; + + } finally { + entryLock.unlock(); + } + + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/rateLimit/RateLimiter.java b/src/main/java/com/example/mohago_nocar/global/rateLimit/RateLimiter.java new file mode 100644 index 0000000..530aed2 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/rateLimit/RateLimiter.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.global.rateLimit; + +import java.util.concurrent.TimeUnit; + +public interface RateLimiter { + + void throttle(); + + static RateLimiter create(long minIntervalMillis) { + return new IntervalRateLimiter(minIntervalMillis); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java b/src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java new file mode 100644 index 0000000..ddee971 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/util/ObjectMapperUtil.java @@ -0,0 +1,40 @@ +package com.example.mohago_nocar.global.util; + +import com.example.mohago_nocar.global.common.exception.InternalServerException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ObjectMapperUtil { + + private final ObjectMapper objectMapper; + + public T readValue(String value, TypeReference typeReference) { + try { + return objectMapper.readValue(value, typeReference); + } catch (JsonProcessingException e) { + throw new InternalServerException(e.getMessage()); + } + } + + public T readValue(String value, Class clazz) { + try { + return objectMapper.readValue(value, clazz); + } catch (JsonProcessingException e) { + throw new InternalServerException(e.getMessage()); + } + } + + public String writeValue(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new InternalServerException(e.getMessage()); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/global/util/RedisStreamHelper.java b/src/main/java/com/example/mohago_nocar/global/util/RedisStreamHelper.java new file mode 100644 index 0000000..c7abda8 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/util/RedisStreamHelper.java @@ -0,0 +1,61 @@ +package com.example.mohago_nocar.global.util; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class RedisStreamHelper { + + private final StringRedisTemplate redisTemplate; + + public RecordId addNewObjectRecord(String streamKey, String payload) { + ObjectRecord record = StreamRecords.newRecord().in(streamKey).ofObject(payload); + return redisTemplate.opsForStream().add(record); + } + + public PendingMessages getPendingMessages( + String streamKeyName, String consumerGroupName, String consumerName, int maxScanNum, Range range) { + return redisTemplate.opsForStream() + .pending( + streamKeyName, + Consumer.from(consumerGroupName, consumerName), + range, + maxScanNum); + } + + public PendingMessages getPendingMessages( + String streamKeyName, String consumerGroupName, int maxScanNum, Range range) { + return redisTemplate.opsForStream() + .pending( + streamKeyName, + consumerGroupName, + range, + maxScanNum); + } + + public void acknowledgeAndDelete(String streamKeyName, String consumerGroupName, RecordId[] recordIds) { + redisTemplate.opsForStream().acknowledge(streamKeyName, consumerGroupName, recordIds); + redisTemplate.opsForStream().delete(streamKeyName, recordIds); + } + + public void acknowledgeAndDelete(String streamKeyName, String consumerGroupName, RecordId recordId) { + redisTemplate.opsForStream().acknowledge(streamKeyName, consumerGroupName, recordId); + redisTemplate.opsForStream().delete(streamKeyName, recordId); + } + + public MapRecord readMessage(String streamKeyName, RecordId recordId) { + List> range = redisTemplate.opsForStream() + .range(streamKeyName, Range.just(recordId.getValue())); + + return range.stream() + .findFirst() + .orElse(null); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/global/util/Result.java b/src/main/java/com/example/mohago_nocar/global/util/Result.java new file mode 100644 index 0000000..bf2a22b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/global/util/Result.java @@ -0,0 +1,15 @@ +package com.example.mohago_nocar.global.util; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Result { + SUCCESS(true), FAILURE(false); + + private final boolean bool; + + public boolean isSuccess() { + return bool; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java b/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java index 9ebbe23..0d7ee89 100644 --- a/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java +++ b/src/main/java/com/example/mohago_nocar/place/application/PlaceService.java @@ -2,22 +2,15 @@ import com.example.mohago_nocar.festival.domain.model.Festival; import com.example.mohago_nocar.festival.domain.service.FestivalUseCase; -import com.example.mohago_nocar.festival.presentation.response.FestivalLocationResponseDto; -import com.example.mohago_nocar.global.common.dto.PagedResponseDto; -import com.example.mohago_nocar.place.application.mapper.FestivalNearPlaceMapper; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlaceImage; -import com.example.mohago_nocar.place.domain.model.OperatingSchedule; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceImageRepository; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceRepository; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.place.domain.converter.PlaceConverter; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.repository.PlaceRepository; import com.example.mohago_nocar.place.domain.service.PlaceUseCase; -import com.example.mohago_nocar.place.infrastructure.externalApi.GoogleApiClient; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; +import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.KakaoApiClient; +import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse; import com.example.mohago_nocar.place.presentation.NearPlaceResponseDto; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -26,65 +19,45 @@ @RequiredArgsConstructor public class PlaceService implements PlaceUseCase { - private final GoogleApiClient googleApiClient; - private final FestivalUseCase festivalUseCase; - private final FestivalNearPlaceRepository festivalNearPlaceRepository; - private final FestivalNearPlaceImageRepository festivalNearPlaceImageRepository; + private static final int RADIUS = 3000; + private static final int PAGE_SIZE = 10; - private static final int RADIUS = 1500; + private final FestivalUseCase festivalUseCase; + private final PlaceRepository placeRepository; + private final KakaoApiClient kakaoApiClient; - @Override @Transactional - public void updateAllFestivalNearbyPlaces() { - List festivals = festivalUseCase.getAllFestivals(); - festivals.forEach(festival -> updateFestivalNearbyPlaces(festival.getId())); - } - - @Transactional(readOnly = true) @Override - public PagedResponseDto getFestivalNearPlaces(Long festivalId, Pageable pageable) { + public List getFestivalNearPlaces(Long festivalId) { Festival festival = festivalUseCase.getFestival(festivalId); - Page pagedPlaces = festivalNearPlaceRepository.getFestivalNearPlaceByFestivalId(festivalId, pageable); - Page nearPlaceResponseDtos = pagedPlaces.map(this::convertPlaceToNearPlaceResponseDto); - return new PagedResponseDto<>(nearPlaceResponseDtos); - } - - private NearPlaceResponseDto convertPlaceToNearPlaceResponseDto(FestivalNearPlace place) { - List images = festivalNearPlaceImageRepository.getAllPlaceImageByPlaceId(place.getId()); - List imageUrls = images.stream().map(FestivalNearPlaceImage::getImageUrl).toList(); - List operatingHours = place.getOperatingSchedule().getOperatingHours().stream().map(OperatingSchedule.OperatingHour::getOperatingHour).toList(); - return NearPlaceResponseDto.of(place, operatingHours, imageUrls); - } - - private void updateFestivalNearbyPlaces(Long festivalId) { - FestivalLocationResponseDto festivalLocation = festivalUseCase.getFestivalLocation(festivalId); - List nearbyPlaces = searchNearbyPlacesWithImages(festivalLocation); - saveNearbyPlacesWithImages(festivalId, nearbyPlaces); - } + List places = placeRepository.getFestivalAroundPlaces(festivalId); + if (places.isEmpty()) { + places = cachePlaces(festivalId, festival.getCoordinate()); + } - private List searchNearbyPlacesWithImages(FestivalLocationResponseDto festivalLocation) { - return googleApiClient.searchNearbyPlacesWithImageUris( - festivalLocation.location().getLatitude(), - festivalLocation.location().getLongitude(), - RADIUS - ); + return PlaceConverter.convertToNearPlaceResponseDtos(festivalId, places); } - private void saveNearbyPlacesWithImages(Long festivalId, List nearbyPlaces) { - nearbyPlaces.forEach(placeDto -> { - FestivalNearPlace savedPlace = saveFestivalNearPlace(festivalId, placeDto); - saveFestivalNearPlaceImages(savedPlace.getId(), placeDto.getPhotos()); - }); + // 아래 메서드 결과는 0 or all이어야함. 캐싱은 festival 주변 장소 단위거든요. + // todo 이를 반영하는 객체 만들기 + @Override + public List getFestivalNearPlacesById(Long festivalId, List placeIds) { + return placeRepository.findByIds(festivalId, placeIds); } - private FestivalNearPlace saveFestivalNearPlace(Long festivalId, PlaceResponseDto placeDto) { - FestivalNearPlace place = FestivalNearPlaceMapper.convertToFestivalNearPlace(festivalId, placeDto); - return festivalNearPlaceRepository.save(place); + @Override + public List cachePlaces(Long festivalId, Coordinate centerCoordinate) { + KakaoPlacesResponse placesFromExternalApi = searchPlacesAround(centerCoordinate); + List places = PlaceConverter.convertToPlaces(placesFromExternalApi); + return placeRepository.saveAllToCache(festivalId, places); } - private void saveFestivalNearPlaceImages(Long placeId, List photos) { - List placeImages = FestivalNearPlaceMapper.convertToFestivalNearPlaceImage(placeId, photos); - placeImages.forEach(festivalNearPlaceImageRepository::save); + private KakaoPlacesResponse searchPlacesAround(Coordinate centerCoordinate) { + return kakaoApiClient.searchAttractionPlaces( + centerCoordinate, + RADIUS, + PAGE_SIZE + ); } } \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/place/application/mapper/FestivalNearPlaceMapper.java b/src/main/java/com/example/mohago_nocar/place/application/mapper/FestivalNearPlaceMapper.java deleted file mode 100644 index 6d350cd..0000000 --- a/src/main/java/com/example/mohago_nocar/place/application/mapper/FestivalNearPlaceMapper.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.mohago_nocar.place.application.mapper; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlaceImage; -import com.example.mohago_nocar.place.domain.model.OperatingSchedule; -import com.example.mohago_nocar.place.domain.model.PlaceType; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; -import java.util.List; - - -public class FestivalNearPlaceMapper { - - public static FestivalNearPlace convertToFestivalNearPlace(Long festivalId, PlaceResponseDto dto) { - - String placeName = dto.getPlaceName(); - OperatingSchedule schedule = getSchedule(dto); - Location location = getLocation(dto); - String address = dto.getAddress(); - String description = dto.getDescription(); - PlaceType placeType = getPlaceType(dto); - String googlePlaceId = dto.getId(); - - return FestivalNearPlace.from(festivalId, placeName, schedule, location, address, description, placeType, googlePlaceId); - } - - private static OperatingSchedule getSchedule(PlaceResponseDto dto) { - return OperatingSchedule.from(dto.getSchedule()); - } - - private static Location getLocation(PlaceResponseDto dto) { - return Location.from(dto.getLongitude(), dto.getLatitude()); - } - - private static PlaceType getPlaceType(PlaceResponseDto dto) { - return PlaceType.from(dto.getPlaceType()); - } - - public static List convertToFestivalNearPlaceImage(Long festivalId, List photos) { - return photos.stream().map(photo -> FestivalNearPlaceImage.from(festivalId, photo)).toList(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/converter/PlaceConverter.java b/src/main/java/com/example/mohago_nocar/place/domain/converter/PlaceConverter.java new file mode 100644 index 0000000..085af6c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/domain/converter/PlaceConverter.java @@ -0,0 +1,36 @@ +package com.example.mohago_nocar.place.domain.converter; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.place.domain.model.*; +import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse; +import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse.KakaoPlaceResponse; +import com.example.mohago_nocar.place.presentation.NearPlaceResponseDto; + +import java.util.List; + +public class PlaceConverter { + + public static List convertToPlaces(KakaoPlacesResponse places) { + return places.documents().stream() + .map(PlaceConverter::convertToPlace) + .toList(); + } + + public static Place convertToPlace(KakaoPlaceResponse dto) { + return Place.from( + dto.id(), + dto.place_name(), + Coordinate.from(dto.x(), dto.y()), + dto.address_name(), + dto.place_url(), + PlaceCategory.getCategoryByCode(dto.category_group_code()) + ); + } + + public static List convertToNearPlaceResponseDtos(Long festivalId, List places) { + return places.stream() + .map(place -> NearPlaceResponseDto.of(festivalId, place)) + .toList(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlace.java b/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlace.java deleted file mode 100644 index 3984a46..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlace.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.example.mohago_nocar.place.domain.model; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import jakarta.persistence.*; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import static jakarta.persistence.EnumType.STRING; -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - -@Entity -@Getter -@NoArgsConstructor(access = PROTECTED) -public class FestivalNearPlace { - - @Id - @GeneratedValue(strategy = IDENTITY) - private Long id; - - @NotNull - private String name; - - @NotNull - private Long festivalId; - - @NotNull - @Embedded - private OperatingSchedule operatingSchedule; - - @NotNull - @Embedded - private Location location; - - @NotNull - private String address; - - @NotNull - @Column(columnDefinition = "TEXT") - private String description; - - @NotNull - @Enumerated(value = STRING) - private PlaceType placeType; - - @NotNull - private String googlePlaceId; - - public static FestivalNearPlace from( - Long festivalId, - String name, - OperatingSchedule operatingSchedule, - Location location, - String address, - String description, - PlaceType placeType, - String googlePlaceId - ) { - return FestivalNearPlace.builder() - .festivalId(festivalId) - .name(name) - .operatingSchedule(operatingSchedule) - .location(location) - .address(address) - .description(description) - .placeType(placeType) - .googlePlaceId(googlePlaceId) - .build(); - } - - @Builder - private FestivalNearPlace( - Long festivalId, - String name, - OperatingSchedule operatingSchedule, - Location location, - String address, - String description, - PlaceType placeType, - String googlePlaceId - ) { - this.festivalId = festivalId; - this.name = name; - this.operatingSchedule = operatingSchedule; - this.location = location; - this.address = address; - this.description = description; - this.placeType = placeType; - this.googlePlaceId = googlePlaceId; - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlaceImage.java b/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlaceImage.java deleted file mode 100644 index 8623e6d..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/model/FestivalNearPlaceImage.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.mohago_nocar.place.domain.model; - -import com.example.mohago_nocar.festival.domain.model.FestivalImage; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - -@Entity -@Getter -@NoArgsConstructor(access = PROTECTED) -public class FestivalNearPlaceImage { - - @Id - @GeneratedValue(strategy = IDENTITY) - private Long id; - - @NotNull - private Long festivalNearPlaceId; - - @NotNull - private String imageUrl; - - public static FestivalNearPlaceImage from(Long festivalId, String imageUrl) { - return FestivalNearPlaceImage.builder() - .festivalNearPlaceId(festivalId) - .imageUrl(imageUrl) - .build(); - } - - @Builder - private FestivalNearPlaceImage(Long festivalNearPlaceId, String imageUrl) { - this.festivalNearPlaceId = festivalNearPlaceId; - this.imageUrl = imageUrl; - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/OperatingSchedule.java b/src/main/java/com/example/mohago_nocar/place/domain/model/OperatingSchedule.java deleted file mode 100644 index aac112f..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/model/OperatingSchedule.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.example.mohago_nocar.place.domain.model; - -import jakarta.persistence.CollectionTable; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Embeddable; -import jakarta.persistence.JoinColumn; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.ArrayList; -import java.util.List; - -import static lombok.AccessLevel.PROTECTED; - -@Embeddable -@NoArgsConstructor(access = PROTECTED) -public class OperatingSchedule { - - private static final int MAX_DAYS_IN_WEEK = 7; - - @ElementCollection - @CollectionTable(name = "operating_hours", joinColumns = @JoinColumn(name = "festival_near_place_id")) - private List schedule = new ArrayList<>(); - - public List getOperatingHours() { - return schedule; - } - - public static OperatingSchedule from(List operatingHours) { - if (operatingHours.size() > MAX_DAYS_IN_WEEK) { - throw new IllegalArgumentException("Operating hours cannot exceed " + MAX_DAYS_IN_WEEK + " days."); - } - - OperatingSchedule operatingSchedule = new OperatingSchedule(); - operatingHours.forEach(operatingSchedule::addOperatingHours); - - return operatingSchedule; - } - - private void addOperatingHours(String operatingHour) { - schedule.add(OperatingHour.from(operatingHour)); - } - - - @Embeddable - @Getter - @NoArgsConstructor(access = PROTECTED) - public static class OperatingHour { - - private String operatingHour; - - public static OperatingHour from(String operatingHour) { - return OperatingHour.builder() - .operatingHour(operatingHour) - .build(); - } - - @Builder - private OperatingHour(String operatingHour) { - this.operatingHour = operatingHour; - } - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java b/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java new file mode 100644 index 0000000..23f66ce --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/domain/model/Place.java @@ -0,0 +1,71 @@ +package com.example.mohago_nocar.place.domain.model; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.EnumType.STRING; + +// 이거 레디스 해시예욤... +@Getter +@NoArgsConstructor +public class Place { + + @NotNull + private String kakaoId; + + @NotNull + private String name; + + @NotNull + private Coordinate coordinate; + + @NotNull + private String address; + + @NotNull + private String placeUrl; + + @NotNull + @Enumerated(value = STRING) + private PlaceCategory category; + + public static Place from( + String kakaoId, + String name, + Coordinate coordinate, + String address, + String placeUrl, + PlaceCategory category + ) { + return Place.builder() + .kakaoId(kakaoId) + .name(name) + .coordinate(coordinate) + .address(address) + .placeUrl(placeUrl) + .category(category) + .build(); + } + + @Builder + private Place( + String kakaoId, + String name, + Coordinate coordinate, + String address, + String placeUrl, + PlaceCategory category + ) { + this.kakaoId = kakaoId; + this.name = name; + this.coordinate = coordinate; + this.address = address; + this.placeUrl = placeUrl; + this.category = category; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceCategory.java b/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceCategory.java new file mode 100644 index 0000000..303237e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceCategory.java @@ -0,0 +1,39 @@ +package com.example.mohago_nocar.place.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RequiredArgsConstructor +@Getter +public enum PlaceCategory { + + ATTRACTION("관광명소", "AT4"), + FOOD("음식점", "FD6"), + CULTURAL_FACILITIES("문화시설", "CT1"); + + private final String name; + private final String code; + + public static List getTravelCategories() { + return List.of(ATTRACTION, FOOD, CULTURAL_FACILITIES); + } + + public static PlaceCategory getCategoryByCode(String code) { + if (code.equals(ATTRACTION.getCode())) { + return ATTRACTION; + } + + if (code.equals(FOOD.getCode())) { + return FOOD; + } + + if (code.equals(CULTURAL_FACILITIES.getCode())) { + return CULTURAL_FACILITIES; + } + + return null; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceType.java b/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceType.java deleted file mode 100644 index 193973f..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/model/PlaceType.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.mohago_nocar.place.domain.model; - -public enum PlaceType { - RESTAURANT, ATTRACTION; - - public static PlaceType from(String placeType) { - if (placeType.contains("음식점") - || placeType.contains("식당") - || placeType.contains("카페") - || placeType.contains("커피숍/커피 전문점") - || placeType.contains("제과점") - || placeType.contains("아이스크림 가게") - ) { - return RESTAURANT; - } - - return ATTRACTION; - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceImageRepository.java b/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceImageRepository.java deleted file mode 100644 index ebeb2f3..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceImageRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.place.domain.repository; - -import com.example.mohago_nocar.place.domain.model.FestivalNearPlaceImage; -import java.util.List; - -public interface FestivalNearPlaceImageRepository { - - FestivalNearPlaceImage save(FestivalNearPlaceImage image); - - List getAllPlaceImageByPlaceId(Long id); -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceRepository.java b/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceRepository.java deleted file mode 100644 index 0773bec..0000000 --- a/src/main/java/com/example/mohago_nocar/place/domain/repository/FestivalNearPlaceRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.mohago_nocar.place.domain.repository; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -public interface FestivalNearPlaceRepository { - - FestivalNearPlace save(FestivalNearPlace place); - - FestivalNearPlace findById(Long id); - - String getPlaceNameByLocation(Location location); - - Page getFestivalNearPlaceByFestivalId(Long festivalId, Pageable pageable); -} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/repository/PlaceRepository.java b/src/main/java/com/example/mohago_nocar/place/domain/repository/PlaceRepository.java new file mode 100644 index 0000000..3ba7804 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/domain/repository/PlaceRepository.java @@ -0,0 +1,14 @@ +package com.example.mohago_nocar.place.domain.repository; + +import com.example.mohago_nocar.place.domain.model.Place; + +import java.util.List; + +public interface PlaceRepository { + + List findByIds(Long festivalId, List placeIds); + + List getFestivalAroundPlaces(Long festivalId); + + List saveAllToCache(Long festivalId, List nearPlaces); +} diff --git a/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java b/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java index 6876a56..4d1d005 100644 --- a/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java +++ b/src/main/java/com/example/mohago_nocar/place/domain/service/PlaceUseCase.java @@ -1,15 +1,17 @@ package com.example.mohago_nocar.place.domain.service; -import com.example.mohago_nocar.global.common.dto.PagedResponseDto; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.place.domain.model.Place; import com.example.mohago_nocar.place.presentation.NearPlaceResponseDto; + import java.util.List; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; public interface PlaceUseCase { - void updateAllFestivalNearbyPlaces(); + List getFestivalNearPlaces(Long festivalId); + + List getFestivalNearPlacesById(Long festivalId, List placeIds); + + List cachePlaces(Long festivalId, Coordinate centerCoordinate); - PagedResponseDto getFestivalNearPlaces(Long festivalId, Pageable pageable); } diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageJpaRepository.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageJpaRepository.java deleted file mode 100644 index 6c3aa11..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageJpaRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure; - -import com.example.mohago_nocar.place.domain.model.FestivalNearPlaceImage; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceImageRepository; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface FestivalNearPlaceImageJpaRepository extends JpaRepository { - - List findAllByFestivalNearPlaceId(Long id); -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageRepositoryImpl.java deleted file mode 100644 index 9385f81..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceImageRepositoryImpl.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure; - -import com.example.mohago_nocar.place.domain.model.FestivalNearPlaceImage; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceImageRepository; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class FestivalNearPlaceImageRepositoryImpl implements FestivalNearPlaceImageRepository { - - private final FestivalNearPlaceImageJpaRepository festivalNearPlaceImageJpaRepository; - - @Override - public FestivalNearPlaceImage save(FestivalNearPlaceImage image) { - return festivalNearPlaceImageJpaRepository.save(image); - } - - @Override - public List getAllPlaceImageByPlaceId(Long id) { - return festivalNearPlaceImageJpaRepository.findAllByFestivalNearPlaceId(id); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceJpaRepository.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceJpaRepository.java deleted file mode 100644 index 641338b..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceJpaRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure; - -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface FestivalNearPlaceJpaRepository extends JpaRepository { - - @Query("SELECT place.name FROM FestivalNearPlace place " + - "WHERE place.location.latitude = :latitude " + - "AND place.location.longitude = :longitude") - Page findNamesByLocation(@Param("latitude") Double latitude, - @Param("longitude") Double longitude, - Pageable pageable); - - Page findAllByFestivalId(Long festivalId, Pageable pageable); -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceRepositoryImpl.java deleted file mode 100644 index d262772..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/FestivalNearPlaceRepositoryImpl.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.global.common.exception.EntityNotFoundException; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Repository; - -import static com.example.mohago_nocar.place.presentation.exception.PlaceErrorCode.PLACE_NOT_FOUND; - -@Repository -@RequiredArgsConstructor -public class FestivalNearPlaceRepositoryImpl implements FestivalNearPlaceRepository { - - private final FestivalNearPlaceJpaRepository festivalNearPlaceJpaRepository; - - @Override - public FestivalNearPlace save(FestivalNearPlace place) { - return festivalNearPlaceJpaRepository.save(place); - } - - @Override - public FestivalNearPlace findById(Long id) { - return festivalNearPlaceJpaRepository.findById(id) - .orElseThrow(()-> new EntityNotFoundException(PLACE_NOT_FOUND)); - } - - @Override - public String getPlaceNameByLocation(Location location) { - PageRequest pageRequest = PageRequest.of(0, 1); - Page result = festivalNearPlaceJpaRepository.findNamesByLocation(location.getLatitude(), location.getLongitude(), pageRequest); - return result.getContent().get(0); - } - - public Page getFestivalNearPlaceByFestivalId(Long festivalId, Pageable pageable) { - return festivalNearPlaceJpaRepository.findAllByFestivalId(festivalId, pageable); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java new file mode 100644 index 0000000..1d4c16a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/infrastructure/PlaceRepositoryImpl.java @@ -0,0 +1,74 @@ +package com.example.mohago_nocar.place.infrastructure; + +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.repository.PlaceRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Repository +@Slf4j +@RequiredArgsConstructor +public class PlaceRepositoryImpl implements PlaceRepository { + + private static final String KEY_PREFIX = "festival:places:"; + + private final RedisTemplate stringRedisTemplate; + private final ObjectMapperUtil objectMapperUtil; + + // 특정 아이디를 가지는 장소만 조회하도록 바꾸면되자나요. 근데 뭐가 더 효율적일지는 모르겠네. + @Override + public List findByIds(Long festivalId, List placeIds) { + List places = getFestivalAroundPlaces(festivalId); + + return places.stream() + .filter(place -> placeIds.contains(place.getKakaoId())) + .toList(); + } + + @Override + public List getFestivalAroundPlaces(Long festivalId) { + String placesInJson = readCache(generateCacheKey(festivalId)); + + if (StringUtils.isEmpty(placesInJson)) { + return Collections.emptyList(); + } + + return objectMapperUtil.readValue(placesInJson, new TypeReference<>() { + }); + } + + @Override + public List saveAllToCache(Long festivalId, List toSavePlaces) { + String key = generateCacheKey(festivalId); + saveToCache(key, toSavePlaces); + return readFromSavedCache(key); + } + + private void saveToCache(String key, List places) { + String placesJson = objectMapperUtil.writeValue(places); + stringRedisTemplate.opsForValue().set(key, placesJson, 2, TimeUnit.HOURS); + } + + private List readFromSavedCache(String key) { + return objectMapperUtil.readValue(readCache(key), new TypeReference<>() { + }); + } + + private String readCache(String redisKey) { + return stringRedisTemplate.opsForValue().get(redisKey); + } + + private String generateCacheKey(Long festivalId) { + return KEY_PREFIX + festivalId; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/GoogleApiClient.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/GoogleApiClient.java deleted file mode 100644 index bc1e0fc..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/GoogleApiClient.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi; - -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.request.GoogleNearPlaceRequest; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.GoogleNearbyPlaceResponse; -import java.net.URI; -import java.util.List; -import java.util.stream.Collectors; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.GooglePlaceImageResponse; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; -import com.example.mohago_nocar.place.infrastructure.externalApi.mapper.GooglePlaceMapper; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; -import org.springframework.web.util.UriComponentsBuilder; - -@Component -@Slf4j -public class GoogleApiClient { - - private final RestClient restClient; - private final String apiKey; - private final String baseUrl; - - private static final int MAX_RESULT_COUNT = 20; - - private static final String RANK_PREFERENCE = "POPULARITY"; - - private static final String LANGUAGE_CODE = "ko"; - - private static final String FIELD_MASK = String.join(",", - "places.displayName", - "places.nationalPhoneNumber", - "places.id", - "places.formattedAddress", - "places.rating", - "places.userRatingCount", - "places.location", - "places.websiteUri", - "places.regularOpeningHours.weekdayDescriptions", - "places.primaryTypeDisplayName", - "places.editorialSummary", - "places.generativeSummary", - "places.photos.name"); - - private static final List REQUEST_PLACE_TYPES = List.of( - "art_gallery", - "museum", - "performing_arts_theater", - "amusement_center", - "amusement_park", - "aquarium", - "banquet_hall", - "bowling_alley", - "casino", - "hiking_area", - "historical_landmark", - "marina", - "movie_rental", - "movie_theater", - "national_park", - "night_club", - "park", - "zoo", - "american_restaurant", - "bakery", - "bar", - "barbecue_restaurant", - "breakfast_restaurant", - "brunch_restaurant", - "cafe", - "chinese_restaurant", - "coffee_shop", - "fast_food_restaurant", - "french_restaurant", - "hamburger_restaurant", - "ice_cream_shop", - "indian_restaurant", - "indonesian_restaurant", - "italian_restaurant", - "japanese_restaurant", - "korean_restaurant", - "mediterranean_restaurant", - "mexican_restaurant", - "middle_eastern_restaurant", - "pizza_restaurant", - "ramen_restaurant", - "restaurant", - "sandwich_shop", - "seafood_restaurant", - "steak_house", - "sushi_restaurant", - "spa", - "gift_shop", - "shopping_mall", - "playground" - ); - - public GoogleApiClient( - RestClient.Builder restClient, - @Value("${google.api-key}") String apiKey, - @Value("${google.url}") String baseUrl) { - this.restClient = restClient.build(); - this.apiKey = apiKey; - this.baseUrl = baseUrl; - } - - public List searchNearbyPlacesWithImageUris(double latitude, double longitude, int radius) { - URI requestURI = buildNearPlaceRequestURI(); - GoogleNearbyPlaceResponse googleNearPlaceResponse = fetchNearPlaceResponse(requestURI, latitude, longitude, radius); - List placeResponseDtos = GooglePlaceMapper.mapGoogleNearPlaceResponseToPlaceResponseDtos(googleNearPlaceResponse); - - return convertPhotoNamesToUris(placeResponseDtos); - } - - private URI buildNearPlaceRequestURI() { - return UriComponentsBuilder.fromUriString(baseUrl) - .path("places:searchNearby") - .build(true) - .toUri(); - } - - private GoogleNearbyPlaceResponse fetchNearPlaceResponse(URI requestURI, double latitude, double longitude, int radius) { - GoogleNearPlaceRequest requestBody = GoogleNearPlaceRequest.of( - REQUEST_PLACE_TYPES, MAX_RESULT_COUNT, latitude, longitude, radius, RANK_PREFERENCE, LANGUAGE_CODE); - - try { - return restClient.post() - .uri(requestURI) - .header("X-Goog-Api-Key", apiKey) - .header("Content-Type", "application/json") - .header("X-Goog-FieldMask", FIELD_MASK) - .body(requestBody) - .retrieve() - .body(GoogleNearbyPlaceResponse.class); - - } catch (Exception e) { - // TODO: 커스텀 에러 변경 - throw new RuntimeException(e.getMessage()); - } - - } - - /** - * Google API 요청을 통해 주어진 PlaceResponseDto List의 photos 필드 값을 photoName에서 photoUri로 변환합니다. - * - * @param placeResponseDtos 변환할 PlaceResponseDto 목록 (변환 전의 photos 필드 값은 photoName) - * @return 변환된 PlaceResponseDto 목록 (photos 필드 값은 photoUri) - */ - private List convertPhotoNamesToUris(List placeResponseDtos) { - return placeResponseDtos.stream() - .map(this::convertPhotoNamesToUrisForDto) - .collect(Collectors.toList()); - } - - private PlaceResponseDto convertPhotoNamesToUrisForDto(PlaceResponseDto placeResponseDto) { - List photoNames = placeResponseDto.getPhotos(); - List photoUris = searchPlaceImagesFrom(photoNames); - - return placeResponseDto.withUpdatedPhotos(photoUris); - } - - private List searchPlaceImagesFrom(List photoNames) { - return photoNames.stream() - .map(this::searchPlaceImageUri) - .collect(Collectors.toList()); - } - - private String searchPlaceImageUri(String photoName) { - URI uri = buildPlaceImageRequestURI(photoName); - GooglePlaceImageResponse imageResponse = fetchPlaceImageResponse(uri); - - return imageResponse.photoUri(); - } - - private URI buildPlaceImageRequestURI(String name) { - return UriComponentsBuilder.fromUriString(baseUrl) - .path(name) - .path("/media") - .queryParam("key", apiKey) - .queryParam("maxHeightPx", 4800) - .queryParam("skipHttpRedirect", true) - .build(true) - .toUri(); - } - - private GooglePlaceImageResponse fetchPlaceImageResponse(URI uri) { - try { - return restClient.get() - .uri(uri) - .retrieve() - .body(GooglePlaceImageResponse.class); - - } catch (Exception e) { - // TODO: 커스텀 에러 변경 - throw new RuntimeException(e.getMessage()); - } - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/GoogleNearPlaceRequest.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/GoogleNearPlaceRequest.java deleted file mode 100644 index a5780b6..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/GoogleNearPlaceRequest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.dto.request; - -import lombok.Builder; -import java.util.List; - - -@Builder -public record GoogleNearPlaceRequest( - List includedTypes, - int maxResultCount, - LocationRestriction locationRestriction, - String rankPreference, - String languageCode -) { - public static GoogleNearPlaceRequest of( - List includedTypes, - int maxResultCount, - double latitude, - double longitude, - double radius, - String rankPreference, - String languageCode - ) { - return GoogleNearPlaceRequest.builder() - .includedTypes(includedTypes) - .maxResultCount(maxResultCount) - .locationRestriction(LocationRestriction.of(latitude, longitude, radius)) - .rankPreference(rankPreference) - .languageCode(languageCode) - .build(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/LocationRestriction.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/LocationRestriction.java deleted file mode 100644 index 99b9b6c..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/request/LocationRestriction.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.dto.request; - -import lombok.Builder; - -@Builder -public record LocationRestriction( - Circle circle -) { - public static LocationRestriction of(double latitude, double longitude, double radius) { - return LocationRestriction.builder() - .circle(Circle.of(latitude, longitude, radius)) - .build(); - } - - @Builder - public record Circle( - Center center, - double radius - ) { - public static Circle of(double latitude, double longitude, double radius) { - return Circle.builder() - .center(Center.of(latitude, longitude)) - .radius(radius) - .build(); - } - } - - @Builder - public record Center( - double latitude, - double longitude - ) { - public static Center of(double latitude, double longitude) { - return Center.builder() - .latitude(latitude) - .longitude(longitude) - .build(); - } - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GoogleNearbyPlaceResponse.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GoogleNearbyPlaceResponse.java deleted file mode 100644 index 462d160..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GoogleNearbyPlaceResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.dto.response; - -import com.fasterxml.jackson.databind.JsonNode; - -public record GoogleNearbyPlaceResponse( - JsonNode places -) { -} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GooglePlaceImageResponse.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GooglePlaceImageResponse.java deleted file mode 100644 index a396389..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/GooglePlaceImageResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.dto.response; - -public record GooglePlaceImageResponse( - String photoUri -) { -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/PlaceResponseDto.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/PlaceResponseDto.java deleted file mode 100644 index c9e1042..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/dto/response/PlaceResponseDto.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.dto.response; - -import lombok.Builder; -import lombok.Getter; - -import java.util.List; - -@Getter -@Builder -public class PlaceResponseDto { - - private final String id; - private final String address; - private final Double latitude; - private final Double longitude; - private final Double rating; - private final Integer ratingCount; - private final String placeName; - private final String placeType; - private final List photos; - private final String editorialSummary; - private final String generativeSummary; - private final List schedule; - - private static final String NO_DESCRIPTION_MESSAGE = "장소 소개가 제공되지 않습니다"; - - private static final String N0_SCHEDULE_MESSAGE = "영업 시간이 제공되지 않는 장소입니다"; - - public static PlaceResponseDto of( - String id, - String address, - Double latitude, - Double longitude, - Double rating, - Integer userRatingCount, - String placeName, - String placeType, - List photos, - String editorialSummary, - String generativeSummary, - List schedule - ) { - return PlaceResponseDto.builder() - .id(id) - .address(address) - .latitude(latitude) - .longitude(longitude) - .rating(rating) - .ratingCount(userRatingCount) - .placeName(placeName) - .placeType(placeType) - .photos(photos) - .editorialSummary(editorialSummary) - .generativeSummary(generativeSummary) - .schedule(schedule) - .build(); - } - - public PlaceResponseDto withUpdatedPhotos(List photos) { - return PlaceResponseDto.builder() - .id(this.id) - .address(this.address) - .latitude(this.latitude) - .longitude(this.longitude) - .rating(this.rating) - .ratingCount(this.ratingCount) - .placeName(this.placeName) - .placeType(this.placeType) - .photos(photos) - .editorialSummary(this.editorialSummary) - .generativeSummary(this.generativeSummary) - .schedule(this.schedule) - .build(); - } - - public String getDescription() { - if (editorialSummary != null && !editorialSummary.isEmpty()) { - return editorialSummary; - } - - if (generativeSummary != null && !generativeSummary.isEmpty()) { - return generativeSummary; - } - - return NO_DESCRIPTION_MESSAGE; - } - - public List getSchedule() { - if (this.schedule.isEmpty()) { - return List.of(N0_SCHEDULE_MESSAGE); - } - - return this.schedule; - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java new file mode 100644 index 0000000..876775b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/KakaoApiClient.java @@ -0,0 +1,50 @@ +package com.example.mohago_nocar.place.infrastructure.externalApi.kakao; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.place.domain.model.PlaceCategory; +import com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response.KakaoPlacesResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +@Component +public class KakaoApiClient { + + private static final String AUTHORIZATION_PREFIX = "KakaoAK "; + + private final String baseUrl; + private final String apiKey; + private final WebClient webClient; + + public KakaoApiClient( + @Value("${kakao.local.category}") String baseUrl, + @Value("${kakao.api-key}") String apiKey) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.webClient = WebClient.builder().build(); + } + + public KakaoPlacesResponse searchAttractionPlaces(Coordinate centerCoordinate, int radius, int size) { + URI uri = UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("x", centerCoordinate.getLongitude()) + .queryParam("y", centerCoordinate.getLatitude()) + .queryParam("radius", radius) + .queryParam("size", size) + .queryParam("category_group_code", PlaceCategory.ATTRACTION.getCode()) + .build(true) + .toUri(); + + return webClient.get() + .uri(uri) + .header("Authorization", AUTHORIZATION_PREFIX + apiKey) + .retrieve() + .bodyToMono(KakaoPlacesResponse.class) + .block(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/dto/response/KakaoPlacesResponse.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/dto/response/KakaoPlacesResponse.java new file mode 100644 index 0000000..c9933e6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/kakao/dto/response/KakaoPlacesResponse.java @@ -0,0 +1,22 @@ +package com.example.mohago_nocar.place.infrastructure.externalApi.kakao.dto.response; + +import java.util.List; + +public record KakaoPlacesResponse( + List documents +) { + + public record KakaoPlaceResponse( + String x, + String y, + String id, + String place_name, + String category_group_code, + String phone, + String address_name, + String place_url, + String distance + ) { + } + +} diff --git a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/mapper/GooglePlaceMapper.java b/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/mapper/GooglePlaceMapper.java deleted file mode 100644 index 5c7ae8e..0000000 --- a/src/main/java/com/example/mohago_nocar/place/infrastructure/externalApi/mapper/GooglePlaceMapper.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.example.mohago_nocar.place.infrastructure.externalApi.mapper; - -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.GoogleNearbyPlaceResponse; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; -import com.fasterxml.jackson.databind.JsonNode; -import java.util.List; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; - -public class GooglePlaceMapper { - - public static List mapGoogleNearPlaceResponseToPlaceResponseDtos(GoogleNearbyPlaceResponse googleNearbyPlaceResponse) { - JsonNode placesNode = googleNearbyPlaceResponse.places(); - - return mapPlacesNodeToResponseDtos(placesNode); - } - - private static List mapPlacesNodeToResponseDtos(JsonNode placesNode) { - - return streamJsonNodeOrEmpty(placesNode) - .map(GooglePlaceMapper::mapPlaceNodeToDto) - .toList(); - } - - private static PlaceResponseDto mapPlaceNodeToDto(JsonNode place) { - String id = place.get("id").asText(); - String address = place.get("formattedAddress").asText(); - - // location - JsonNode locationNode = place.get("location"); - Double latitude = locationNode.get("latitude").asDouble(); - Double longitude = locationNode.get("longitude").asDouble(); - - // place name - String placeName = place.get("displayName").get("text").asText(); - - // place type (nullable) - String placeType = place.has("primaryTypeDisplayName") && !place.get("primaryTypeDisplayName").isNull() - ? place.get("primaryTypeDisplayName").get("text").asText() : "unknown"; - - // rating (nullable) - Double rating = place.has("rating") && !place.get("rating").isNull() - ? place.get("rating").asDouble() : (double) 0; - - // userRatingCount (nullable) - Integer userRatingCount = place.has("userRatingCount") && !place.get("userRatingCount").isNull() - ? place.get("userRatingCount").asInt() : 0; - - // photos (nullable) - JsonNode photoNodes = place.has("photos") && !place.get("photos").isNull() - ? place.get("photos") : null; - List photoNames = extractPhotoName(photoNodes); - - // summary (nullable) - String editorialSummary = place.has("editorialSummary") && !place.get("editorialSummary").isNull() - ? place.get("editorialSummary").get("text").asText() : null; - - String generativeSummary = place.has("generativeSummary") && !place.get("generativeSummary").isNull() - ? place.get("generativeSummary").get("description").get("text").asText() : null; - - // schedule (nullable) - JsonNode scheduleNode = place.has("regularOpeningHours") && !place.get("regularOpeningHours").isNull() - ? place.get("regularOpeningHours").get("weekdayDescriptions") : null; - List schedule = extractSchedule(scheduleNode); - - return PlaceResponseDto.of( - id, - address, - latitude, - longitude, - rating, - userRatingCount, - placeName, - placeType, - photoNames, - editorialSummary, - generativeSummary, - schedule - ); - } - - private static List extractPhotoName(JsonNode photoNodes) { - return streamJsonNodeOrEmpty(photoNodes) - .map(photo -> photo.get("name").asText()) - .toList(); - } - - private static List extractSchedule(JsonNode scheduleNode) { - return streamJsonNodeOrEmpty(scheduleNode) - .map(JsonNode::asText) - .toList(); - } - - private static Stream streamJsonNodeOrEmpty(JsonNode node) { - if (node == null || !node.isArray()) { - return Stream.empty(); - } - return StreamSupport.stream(node.spliterator(), false); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/presentation/NearPlaceResponseDto.java b/src/main/java/com/example/mohago_nocar/place/presentation/NearPlaceResponseDto.java deleted file mode 100644 index 4eaeb78..0000000 --- a/src/main/java/com/example/mohago_nocar/place/presentation/NearPlaceResponseDto.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.example.mohago_nocar.place.presentation; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import com.example.mohago_nocar.place.domain.model.OperatingSchedule; -import com.example.mohago_nocar.place.domain.model.PlaceType; -import java.util.List; -import lombok.Builder; - -@Builder -public record NearPlaceResponseDto( - Long id, - String name, - Long festivalId, - List operatingSchedule, - Location location, - String address, - String description, - PlaceType placeType, - String googlePlaceId, - List imageUrlList -) { - public static NearPlaceResponseDto of(FestivalNearPlace festivalNearPlace, List operatingHours, List imageUrlList) { - return new NearPlaceResponseDtoBuilder() - .id(festivalNearPlace.getId()) - .name(festivalNearPlace.getName()) - .festivalId(festivalNearPlace.getFestivalId()) - .operatingSchedule(operatingHours) - .location(festivalNearPlace.getLocation()) - .address(festivalNearPlace.getAddress()) - .description(festivalNearPlace.getDescription()) - .placeType(festivalNearPlace.getPlaceType()) - .googlePlaceId(festivalNearPlace.getGooglePlaceId()) - .imageUrlList(imageUrlList) - .build(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java b/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java index b2c65e9..1ff3817 100644 --- a/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java +++ b/src/main/java/com/example/mohago_nocar/place/presentation/PlaceController.java @@ -3,10 +3,10 @@ import com.example.mohago_nocar.global.common.dto.PagedResponseDto; import com.example.mohago_nocar.global.common.response.ApiResponse; import com.example.mohago_nocar.place.domain.service.PlaceUseCase; -import com.example.mohago_nocar.place.infrastructure.externalApi.dto.response.PlaceResponseDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import com.example.mohago_nocar.place.presentation.NearPlaceResponseDto; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.*; @@ -14,20 +14,23 @@ import java.util.List; @RestController -@RequestMapping("/api/v1/nearby-places") +@RequestMapping("/api/v1/festivals") @RequiredArgsConstructor @Tag(name = "Place", description = "축제 주변 장소") public class PlaceController { private final PlaceUseCase placeUseCase; - @Operation(summary = "축제 주변 장소 업데이트", description = "축제 주변 장소 정보를 업데이트합니다. ") - @PatchMapping("/update") - public ApiResponse> updateFestivalNearPlaces() { - placeUseCase.updateAllFestivalNearbyPlaces(); - return ApiResponse.ok(null); + @Operation(summary = "축제 주변 장소 조회하기", description = "축제 주변 장소 정보를 조회합니다. ") + @GetMapping("/{festivalId}/nearby-places") + public ApiResponse> getFestivalNearPlaces( + @PathVariable(name = "festivalId") Long festivalId + ) { + List places = placeUseCase.getFestivalNearPlaces(festivalId); + return ApiResponse.ok(places); } +/* @Operation(summary = "축제 주변 장소 조회하기", description = "축제 주변 장소 정보를 조회합니다. ") @GetMapping("/{festivalId}") public ApiResponse> getFestivalNearPlaces( @@ -38,5 +41,6 @@ public ApiResponse> getFestivalNearPlaces Pageable pageable = PageRequest.of(page, size); PagedResponseDto places = placeUseCase.getFestivalNearPlaces(festivalId, pageable); return ApiResponse.ok(places); - } + }*/ + } diff --git a/src/main/java/com/example/mohago_nocar/place/presentation/response/NearPlaceResponseDto.java b/src/main/java/com/example/mohago_nocar/place/presentation/response/NearPlaceResponseDto.java new file mode 100644 index 0000000..d8d26ee --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/place/presentation/response/NearPlaceResponseDto.java @@ -0,0 +1,29 @@ +package com.example.mohago_nocar.place.presentation; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.place.domain.model.Place; + +import lombok.Builder; + +@Builder +public record NearPlaceResponseDto( + String id, + String name, + Long festivalId, + Coordinate coordinate, + String address, + String placeUrl, + String category +) { + public static NearPlaceResponseDto of(Long festivalId, Place place) { + return new NearPlaceResponseDtoBuilder() + .id(place.getKakaoId()) + .name(place.getName()) + .festivalId(festivalId) + .coordinate(place.getCoordinate()) + .address(place.getAddress()) + .placeUrl(place.getPlaceUrl()) + .category(place.getCategory().name()) + .build(); + } +} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java b/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java deleted file mode 100644 index 2c5f677..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/application/TravelPlanService.java +++ /dev/null @@ -1,229 +0,0 @@ -package com.example.mohago_nocar.plan.application; - -import com.example.mohago_nocar.festival.domain.model.Festival; -import com.example.mohago_nocar.festival.domain.repository.FestivalRepository; -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.global.common.exception.InvalidValueException; -import com.example.mohago_nocar.place.domain.model.FestivalNearPlace; -import com.example.mohago_nocar.place.domain.repository.FestivalNearPlaceRepository; -import com.example.mohago_nocar.plan.domain.service.TravelPlanUseCase; -import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; -import com.example.mohago_nocar.plan.presentation.response.PlanTravelCourseResponseDto; -import com.example.mohago_nocar.transit.domain.model.TransitInfo; -import com.example.mohago_nocar.transit.domain.model.WalkPath; -import com.example.mohago_nocar.transit.domain.service.TransitUseCase; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayDistanceException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.time.LocalDate; -import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode.TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD; - -@Service -@RequiredArgsConstructor -@Slf4j -public class TravelPlanService implements TravelPlanUseCase { - - private final FestivalNearPlaceRepository festivalNearPlaceRepository; - private final FestivalRepository festivalRepository; - private final TransitUseCase transitUseCase; - - private static final int EARTH_RADIUS = 6371; - - private int calcTravelTime(List route, Map> transitMaps) { - int n = route.size(); - - int travelTime = 0; - for (int i = 0; i < n - 1; i++) { - travelTime += transitMaps.get(route.get(i)).get(route.get(i + 1)).getTotalTime(); - } - return travelTime; - } - - private void routeBacktracking(int k, List locations, Map> transitMaps, - List optimal, List route, List isSelected) { - int n = locations.size(); - - if (k == n) - { - int t1 = calcTravelTime(optimal, transitMaps); - int t2 = calcTravelTime(route, transitMaps); - - if (t1 > t2) { - for (int i = 0; i < n; i++) { - optimal.set(i, route.get(i)); - } - } - return; - } - - for (int i = 0; i < n; i++) { - if (isSelected.get(i)) { - continue; - } - - isSelected.set(i, true); - route.add(locations.get(i)); - routeBacktracking(k + 1, locations, transitMaps, optimal, route, isSelected); - route.removeLast(); - isSelected.set(i, false); - } - } - - private Double convertLongitudeToKmDist(Double dx, Double stdLatitude) { - - return EARTH_RADIUS * dx * Math.cos(stdLatitude) * Math.PI / 180; - } - - private Double convertLatitudeToKmDist(Double dy) { - - return EARTH_RADIUS * dy * Math.PI / 180; - } - - private List getOptimalRoute(List allLocations) { - int locationCount = allLocations.size(); - Map> fromToTransitInfoMap = new HashMap<>(); - - for (int fromIndex = 0; fromIndex < locationCount; fromIndex++) { - Map toLocationTransitInfoMap = new HashMap<>(); - - for (int toIndex = 0; toIndex < locationCount; toIndex++) { - - if (fromIndex == toIndex) { - continue; - } - - Location fromLocation = allLocations.get(fromIndex); - Location toLocation = allLocations.get(toIndex); - - TransitInfo transitInfo = getTransitInfoBetweenLocations(fromLocation, toLocation); - toLocationTransitInfoMap.put(toLocation, transitInfo); - } - - fromToTransitInfoMap.put(allLocations.get(fromIndex), toLocationTransitInfoMap); - } - - List route = new ArrayList<>(); - List isSelected = new ArrayList<>(); - List optimalRoute = new ArrayList<>(); - for (int i = 0; i < locationCount; i++) { - isSelected.add(false); - optimalRoute.add(allLocations.get(i)); - } - - routeBacktracking(0, allLocations, fromToTransitInfoMap , optimalRoute, route, isSelected); - return optimalRoute; - } - - @Override - public List planCourse(PlanTravelCourseRequestDto dto) { - Festival festival = validateAndGetFestival(dto); - List travelPlaces = getTravelPlaces(dto.travelPlaceIds()); - - List allLocations = combineLocations(festival, travelPlaces); - List optimizedRoute = getOptimalRoute(allLocations); - - Map locationNameInfo = createLocationNameMap(festival, optimizedRoute); - - return createTravelCourse(optimizedRoute, locationNameInfo); - } - - private Festival validateAndGetFestival(PlanTravelCourseRequestDto dto) { - Festival festival = festivalRepository.getFestivalById(dto.festivalId()); - ensureTravelDateDuringFestival(festival, dto.travelDate()); - return festival; - } - - private void ensureTravelDateDuringFestival(Festival festival, LocalDate travelDate) { - if (!festival.isDateDuringFestival(travelDate)) { - throw new InvalidValueException(TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD); - } - } - - private List getTravelPlaces(List placeIds) { - return placeIds.stream() - .map(festivalNearPlaceRepository::findById) - .toList(); - } - - private List combineLocations(Festival festival, List travelPlaces) { - return Stream.concat( - Stream.of(festival.getLocation()), - travelPlaces.stream().map(FestivalNearPlace::getLocation) - ).toList(); - } - - private Map createLocationNameMap(Festival festival, List locations) { - return locations.stream() - .collect(Collectors.toMap( - location -> location, - location -> getPlaceName(festival, location) - )); - } - - private String getPlaceName(Festival festival, Location location) { - if (location.equals(festival.getLocation())) { - return festival.getName(); - } - - return festivalNearPlaceRepository.getPlaceNameByLocation(location); - } - - private List createTravelCourse(List optimizedRoute, Map locationNameInfo) { - List travelCourse = new ArrayList<>(); - - for (int i = 0; i < optimizedRoute.size() - 1; i++) { - Location fromLocation = optimizedRoute.get(i); - Location toLocation = optimizedRoute.get(i + 1); - - String fromName = locationNameInfo.get(fromLocation); - String toName = locationNameInfo.get(toLocation); - - TransitInfo transitInfo = getTransitInfoBetweenLocations(fromLocation, toLocation); - - travelCourse.add(createResponseDto(fromLocation, fromName, toLocation, toName, transitInfo)); - } - - return travelCourse; - } - - private TransitInfo getTransitInfoBetweenLocations(Location fromLocation, Location toLocation) { - try { - return transitUseCase.findRouteTransitBetweenPlaces(fromLocation, toLocation); - - } catch (OdsayDistanceException e) { - double dist = getKmDist(fromLocation, toLocation); - int totalTime = (int) Math.round(dist * 15); - WalkPath walkPath = new WalkPath(dist, totalTime); - - return TransitInfo.from(totalTime, dist, List.of(walkPath)); - } - } - - /** - * 두 위치(Location) 사이의 거리를 킬로미터 단위로 계산합니다. - * @param startLocation - * @param endLocation - * @return 두 위치(Location) 사이의 거리 - */ - private Double getKmDist(Location startLocation, Location endLocation) { - Double dx = Math.abs(startLocation.getLongitude() - endLocation.getLongitude()); - dx = Math.min(dx, 360 - dx); - - Double dy = Math.abs(startLocation.getLatitude() - endLocation.getLatitude()); - - Double longitudeDist = convertLongitudeToKmDist(dx, startLocation.getLatitude()); - Double latitudeDist = convertLatitudeToKmDist(dy); - - return Math.sqrt(longitudeDist * longitudeDist + latitudeDist * latitudeDist); - } - - private PlanTravelCourseResponseDto createResponseDto(Location fromLocation, String fromName, Location toLocation, String toName, TransitInfo transitInfo) { - return PlanTravelCourseResponseDto.of(fromLocation, fromName, toLocation, toName, transitInfo); - } -} diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCoursePlanServiceV1.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCoursePlanServiceV1.java new file mode 100644 index 0000000..7a54ffb --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/TravelCoursePlanServiceV1.java @@ -0,0 +1,177 @@ +package com.example.mohago_nocar.plan.application.v1; + +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.festival.domain.repository.FestivalRepository; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.global.common.exception.InvalidValueException; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.service.PlaceUseCase; +import com.example.mohago_nocar.plan.application.v1.strategy.RouteOptimizationStrategy; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.plan.domain.service.TravelCoursePlanUseCaseV1; +import com.example.mohago_nocar.plan.presentation.v1.PlanTravelCourseRequestDto; +import com.example.mohago_nocar.plan.application.v1.response.PlanTravelCourseResponseDto; +import com.example.mohago_nocar.plan.application.v1.response.TravelRouteResponseDto; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.deprecated.TransitRouteApiExecutor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static com.example.mohago_nocar.plan.presentation.exception.PlanErrorCode.TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD; + +@Deprecated +@Service +@RequiredArgsConstructor +@Slf4j +public class TravelCoursePlanServiceV1 implements TravelCoursePlanUseCaseV1 { + + private final FestivalRepository festivalRepository; + private final PlaceUseCase placeService; + private final RouteOptimizationStrategy routeOptimizationStrategy; + private final ExecutorService virtualThreadExecutor; + private final DistanceDurationApiAdapter distanceDurationApiAdapter; + private final TransitRouteApiExecutor transitRouteApiExecutor; + + @Override + public CompletableFuture planCourse(PlanTravelCourseRequestDto dto) { + Festival festival = validateAndGetFestival(dto); + List attractions = getAttractions(festival, dto.placeIds()); + + Map> namesByCoordinate = mergeFestivalAndAttractionName(festival, attractions); + + List optimalRouteCoordinates = findOptimalRoute(namesByCoordinate); + List optimalRouteLocations = mapCoordinatesToLocations(namesByCoordinate, optimalRouteCoordinates); + + return transitRouteApiExecutor.execute(optimalRouteLocations) + .thenApply(routes -> routes.stream().map(TravelRouteResponseDto::of).toList()) + .thenApply(PlanTravelCourseResponseDto::of); + } + + private List findOptimalRoute(Map> namesByCoordinate) { + var coordinates = collectCoordinate(namesByCoordinate); + var routeMetrics = fetchDistanceAndDurations(coordinates); + + return routeOptimizationStrategy.calculateOptimalRoute(coordinates, routeMetrics); + } + + private List collectCoordinate(Map> namesByCoordinate) { + return namesByCoordinate.keySet().stream().toList(); + } + + /** + * 좌표 간의 거리(km), 이동 시간(minutes)를 가져오는 외부 API를 호출하여 응답을 생성합니다. + * @param coordinates 거리, 이동 시간을 구하는 대상 좌표 + * @return 좌표 간의 거리 및 이동시간 + */ + private List fetchDistanceAndDurations(List coordinates) { + var futures = IntStream.range(0, coordinates.size()) + .mapToObj(index -> asyncGetDistanceDuration(coordinates, index)) + .toList(); + + return futures.stream() + .map(this::awaitFutureResult) + .flatMap(Collection::stream) + .toList(); + } + + private Future> asyncGetDistanceDuration(List coordinates, int index) { + return virtualThreadExecutor.submit(() -> distanceDurationApiCall(index, coordinates)); + } + + private List distanceDurationApiCall(int index, List coordinates) { + Coordinate origin = coordinates.get(index); + List destinations = createDestination(coordinates, index); + + return distanceDurationApiAdapter.getDistanceAndDuration(origin, destinations); + } + + private List mapCoordinatesToLocations( + Map> namesByCoordinate, + List coordinates + ) { + return coordinates.stream() + .flatMap(coordinate -> { + List names = namesByCoordinate.get(coordinate); + return names.stream() + .map(name -> Location.of(name, coordinate)); + }).toList(); + } + + private List createDestination(List coordinates, int excludeIndex) { + return IntStream.range(0, coordinates.size()) + .filter(index -> index != excludeIndex) + .mapToObj(coordinates::get) + .toList(); + } + + private Festival validateAndGetFestival(PlanTravelCourseRequestDto dto) { + var festival = festivalRepository.getFestivalById(dto.festivalId()); + ensureTravelDateDuringFestival(festival, dto.travelDate()); + return festival; + } + + private void ensureTravelDateDuringFestival(Festival festival, LocalDate travelDate) { + if (!festival.isDateDuringFestival(travelDate)) { + throw new InvalidValueException(TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD); + } + } + + private List getAttractions(Festival festival, List placeIds) { + List places = placeService.getFestivalNearPlacesById(festival.getId(), placeIds); + if (places.isEmpty()) { + places = placeService.cachePlaces(festival.getId(), festival.getCoordinate()).stream() + .filter(place -> placeIds.contains(place.getKakaoId())) + .toList(); + } + return places; + } + + private Map> mergeFestivalAndAttractionName(Festival festival, List attractions) { + var festivalNameByCoordinate = mapFestivalNameToCoordinate(festival); + var placeNamesByCoordinate = mapPlaceNamesToCoordinate(attractions); + + return mergeNameMaps(festivalNameByCoordinate, placeNamesByCoordinate); + } + + private Map mapFestivalNameToCoordinate(Festival festival) { + return Map.of(festival.getCoordinate(), festival.getName()); + } + + private Map> mapPlaceNamesToCoordinate(List attractions) { + return attractions.stream() + .collect(Collectors.groupingBy( + Place::getCoordinate, Collectors.mapping(Place::getName, Collectors.toList()) + )); + } + + private Map> mergeNameMaps(Map festivalNameByCoordinate, Map> placeNamesByCoordinate) { + festivalNameByCoordinate.forEach((coordinate, festivalName) -> { + placeNamesByCoordinate.merge( + coordinate, + List.of(festivalName), + (existingNames, newNames) -> { + existingNames.addAll(newNames); + return existingNames; + }); + }); + + return placeNamesByCoordinate; + } + + private List awaitFutureResult(Future> future) { + try { + return future.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/BusPathResponseDto.java similarity index 78% rename from src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/response/BusPathResponseDto.java index cff2d91..e8bca2b 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/BusPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/BusPathResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.response; +package com.example.mohago_nocar.plan.application.v1.response; import com.example.mohago_nocar.transit.domain.model.BusPath; import com.example.mohago_nocar.transit.domain.model.SubPath; @@ -23,16 +23,16 @@ public static BusPathResponseDto of(SubPath subPath) { BusPath busPath = (BusPath) subPath; return BusPathResponseDto.builder() - .distance(busPath.getDistance()) - .sectionTime(busPath.getSectionTime()) + .distance(busPath.getDistanceKm()) + .sectionTime(busPath.getTimeTakenMin()) .busNo(busPath.getBusNo()) .busType(busPath.getBusType()) .startPlaceName(busPath.getStartName()) - .startLongitude(busPath.getStartX()) - .startLatitude(busPath.getStartY()) + .startLongitude(busPath.getStartCoordinate().getLongitude()) + .startLatitude(busPath.getStartCoordinate().getLatitude()) .endPlaceName(busPath.getEndName()) - .endLongitude(busPath.getEndX()) - .endLatitude(busPath.getEndY()) + .endLongitude(busPath.getEndCoordinate().getLongitude()) + .endLatitude(busPath.getEndCoordinate().getLatitude()) .build(); } diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/response/PlanTravelCourseResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/PlanTravelCourseResponseDto.java new file mode 100644 index 0000000..2a5d813 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/PlanTravelCourseResponseDto.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.plan.application.v1.response; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record PlanTravelCourseResponseDto( + List travelRoutes +) { + + public static PlanTravelCourseResponseDto of(List travelRoutes) { + return PlanTravelCourseResponseDto.builder() + .travelRoutes(travelRoutes) + .build(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubPathResponseDto.java similarity index 87% rename from src/main/java/com/example/mohago_nocar/plan/presentation/response/SubPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubPathResponseDto.java index a8718d0..50ae3c9 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubPathResponseDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.response; +package com.example.mohago_nocar.plan.application.v1.response; import com.example.mohago_nocar.transit.domain.model.PathType; import lombok.Getter; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubwayPathResponseDto.java similarity index 77% rename from src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubwayPathResponseDto.java index f32eab4..453da08 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/SubwayPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/SubwayPathResponseDto.java @@ -1,10 +1,8 @@ -package com.example.mohago_nocar.plan.presentation.response; +package com.example.mohago_nocar.plan.application.v1.response; -import com.example.mohago_nocar.transit.domain.model.PathType; import com.example.mohago_nocar.transit.domain.model.SubPath; import com.example.mohago_nocar.transit.domain.model.SubwayPath; import lombok.Builder; -import lombok.Generated; import lombok.Getter; import static com.example.mohago_nocar.transit.domain.model.PathType.SUBWAY; @@ -24,15 +22,15 @@ public static SubwayPathResponseDto of(SubPath subPath) { SubwayPath subwayPath = (SubwayPath) subPath; return SubwayPathResponseDto.builder() - .distance(subwayPath.getDistance()) - .sectionTime(subwayPath.getSectionTime()) + .distance(subwayPath.getDistanceKm()) + .sectionTime(subwayPath.getTimeTakenMin()) .subwayLineName(subwayPath.getSubwayLineName()) .startPlaceName(subwayPath.getStartName()) - .startLongitude(subwayPath.getStartX()) - .startLatitude(subwayPath.getStartY()) + .startLongitude(subwayPath.getStartCoordinate().getLongitude()) + .startLatitude(subwayPath.getStartCoordinate().getLatitude()) .endPlaceName(subwayPath.getEndName()) - .endLongitude(subwayPath.getEndX()) - .endLatitude(subwayPath.getEndY()) + .endLongitude(subwayPath.getEndCoordinate().getLongitude()) + .endLatitude(subwayPath.getEndCoordinate().getLatitude()) .build(); } diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/response/TravelRouteResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/TravelRouteResponseDto.java new file mode 100644 index 0000000..1f6f277 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/TravelRouteResponseDto.java @@ -0,0 +1,53 @@ +package com.example.mohago_nocar.plan.application.v1.response; + +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import lombok.Builder; + +import java.util.List; + +@Builder +public record TravelRouteResponseDto( + int totalTime, + double totalDistance, + Location origin, + Location destination, + List subPaths +) { + + public static TravelRouteResponseDto of(TransitRoute transitRoute) { + return TravelRouteResponseDto.builder() + .origin(Location.builder() + .latitude(transitRoute.getOrigin().getCoordinate().getLatitude()) + .longitude(transitRoute.getOrigin().getCoordinate().getLongitude()) + .name(transitRoute.getOrigin().getName()) + .build()) + .destination(Location.builder() + .latitude(transitRoute.getDestination().getCoordinate().getLatitude()) + .longitude(transitRoute.getDestination().getCoordinate().getLongitude()) + .name(transitRoute.getDestination().getName()) + .build()) + .totalTime(transitRoute.getTotalTime()) + .totalDistance(transitRoute.getTotalDistance()) + .subPaths(transitRoute.getSubPaths().stream() + .map(subPath -> + switch (subPath.getPathType()) { + case BUS -> BusPathResponseDto.of(subPath); + case WALK -> WalkPathResponseDto.of(subPath); + case SUBWAY -> SubwayPathResponseDto.of(subPath); + }) + .toList() + ) + .build(); + + } + + @Builder + private record Location( + String name, + Double longitude, + Double latitude + ){ + + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/WalkPathResponseDto.java similarity index 73% rename from src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java rename to src/main/java/com/example/mohago_nocar/plan/application/v1/response/WalkPathResponseDto.java index 9098c1a..cfc72fd 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/WalkPathResponseDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/response/WalkPathResponseDto.java @@ -1,6 +1,5 @@ -package com.example.mohago_nocar.plan.presentation.response; +package com.example.mohago_nocar.plan.application.v1.response; -import com.example.mohago_nocar.transit.domain.model.PathType; import com.example.mohago_nocar.transit.domain.model.SubPath; import com.example.mohago_nocar.transit.domain.model.WalkPath; import lombok.Builder; @@ -15,8 +14,8 @@ public static WalkPathResponseDto of(SubPath subPath) { WalkPath walkPath = (WalkPath) subPath; return WalkPathResponseDto.builder() - .distance(walkPath.getDistance()) - .sectionTime(walkPath.getSectionTime()) + .distance(walkPath.getDistanceKm()) + .sectionTime(walkPath.getTimeTakenMin()) .build(); } diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/RouteOptimizationStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/RouteOptimizationStrategy.java new file mode 100644 index 0000000..02e9f34 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/RouteOptimizationStrategy.java @@ -0,0 +1,12 @@ +package com.example.mohago_nocar.plan.application.v1.strategy; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; + +import java.util.List; + +public interface RouteOptimizationStrategy { + + List calculateOptimalRoute(List coordinates, List routeMetrics); + +} \ No newline at end of file diff --git a/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/ShortestTimeRouteStrategy.java b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/ShortestTimeRouteStrategy.java new file mode 100644 index 0000000..3835d79 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/application/v1/strategy/ShortestTimeRouteStrategy.java @@ -0,0 +1,116 @@ +package com.example.mohago_nocar.plan.application.v1.strategy; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.global.common.exception.InternalServerException; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.*; + +@Component +@Slf4j +public class ShortestTimeRouteStrategy implements RouteOptimizationStrategy { + + private static final int FIRST = 0; + + @Override + public List calculateOptimalRoute(List coordinates, List routeMetrics) { + int locationCount = coordinates.size(); + Map> fromToTransitInfoMap = new HashMap<>(); + + for (int originIndex = FIRST; originIndex < locationCount; originIndex++) { + Map toLocationTransitInfoMap = new HashMap<>(); + + for (int destinationIndex = FIRST; destinationIndex < locationCount; destinationIndex++) { + + if (originIndex == destinationIndex) { + continue; + } + + Coordinate origin = coordinates.get(originIndex); + Coordinate destination = coordinates.get(destinationIndex); + + RouteMetrics routeSpec = getMatchedRouteSpec(origin, destination, routeMetrics); + toLocationTransitInfoMap.put(destination, routeSpec); + } + + fromToTransitInfoMap.put(coordinates.get(originIndex), toLocationTransitInfoMap); + } + + List route = new ArrayList<>(); + List isSelected = new ArrayList<>(); + List optimalRoute = new ArrayList<>(); + for (int i = 0; i < locationCount; i++) { + isSelected.add(false); + optimalRoute.add(coordinates.get(i)); + } + + routeBacktracking(0, coordinates, fromToTransitInfoMap , optimalRoute, route, isSelected); + return optimalRoute; + } + + private RouteMetrics getMatchedRouteSpec( + Coordinate origin, + Coordinate destination, + List routeMetricsBetweenLocations + ) { + Optional routeMetrics = routeMetricsBetweenLocations.stream() + .filter(route -> route.isEqualLocation(origin, destination)) + .findFirst(); + + if (routeMetrics.isEmpty()) { + log.error("origin-{}, destination-{}를 가지는 RouteSpec을 찾을 수 없습니다.", origin, destination); + throw new InternalServerException(); + } + + return routeMetrics.get(); + } + + private void routeBacktracking( + int k, + List coordinates, + Map> transitMaps, + List optimal, + List route, + List isSelected + ) { + int n = coordinates.size(); + + if (k == n) + { + int t1 = calcTravelTime(optimal, transitMaps); + int t2 = calcTravelTime(route, transitMaps); + + if (t1 > t2) { + for (int i = 0; i < n; i++) { + optimal.set(i, route.get(i)); + } + } + return; + } + + for (int i = 0; i < n; i++) { + if (isSelected.get(i)) { + continue; + } + + isSelected.set(i, true); + route.add(coordinates.get(i)); + routeBacktracking(k + 1, coordinates, transitMaps, optimal, route, isSelected); + route.removeLast(); + isSelected.set(i, false); + } + } + + private int calcTravelTime(List route, Map> routeMaps) { + int n = route.size(); + + int travelTime = 0; + for (int i = 0; i < n - 1; i++) { + travelTime += routeMaps.get(route.get(i)).get(route.get(i + 1)).durationInMinutes(); + } + return travelTime; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java b/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java new file mode 100644 index 0000000..5f2522e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/domain/model/Location.java @@ -0,0 +1,57 @@ +package com.example.mohago_nocar.plan.domain.model; + +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.place.domain.model.Place; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + * 장소의 이름과 경위도 + */ +@Getter +@NoArgsConstructor +@ToString +@Embeddable +public class Location { + + private String name; + + @Embedded + private Coordinate coordinate; + + public static Location of(Festival festival) { + return Location.builder() + .name(festival.getName()) + .coordinate(festival.getCoordinate()) + .build(); + } + + public static Location of(Place place) { + return Location.builder() + .name(place.getName()) + .coordinate(place.getCoordinate()) + .build(); + } + + public static Location of(String name, Coordinate coordinate) { + return Location.builder() + .name(name) + .coordinate(coordinate) + .build(); + } + + @Builder + private Location(String name, Coordinate coordinate) { + this.name = name; + this.coordinate = coordinate; + } + + public boolean hasSameCoordinate(Location destination) { + return this.coordinate.equals(destination.coordinate); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCoursePlanUseCaseV1.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCoursePlanUseCaseV1.java new file mode 100644 index 0000000..8b85663 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelCoursePlanUseCaseV1.java @@ -0,0 +1,12 @@ +package com.example.mohago_nocar.plan.domain.service; + +import com.example.mohago_nocar.plan.presentation.v1.PlanTravelCourseRequestDto; +import com.example.mohago_nocar.plan.application.v1.response.PlanTravelCourseResponseDto; + +import java.util.concurrent.CompletableFuture; + +public interface TravelCoursePlanUseCaseV1 { + + CompletableFuture planCourse(PlanTravelCourseRequestDto dto); + +} diff --git a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java b/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java deleted file mode 100644 index 37b881f..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/domain/service/TravelPlanUseCase.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.plan.domain.service; - -import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; -import com.example.mohago_nocar.plan.presentation.response.PlanTravelCourseResponseDto; - -import java.util.List; - -public interface TravelPlanUseCase { - - List planCourse(PlanTravelCourseRequestDto dto); -} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/exception/PlanErrorCode.java b/src/main/java/com/example/mohago_nocar/plan/presentation/exception/PlanErrorCode.java index fbd71f5..114ee0c 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/exception/PlanErrorCode.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/exception/PlanErrorCode.java @@ -10,6 +10,7 @@ public enum PlanErrorCode implements Status { TRAVEL_DATE_NOT_IN_FESTIVAL_PERIOD(HttpStatus.BAD_REQUEST, "PLAN400", "여행 날짜가 축제 기간을 벗어났습니다"), + BATCH_TASK_NOT_FOUND(HttpStatus.BAD_REQUEST, "BATCH_TASK_NOT_FOUND", "존재하지 않는 배치 작업입니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java deleted file mode 100644 index b5feb79..0000000 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/response/PlanTravelCourseResponseDto.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.example.mohago_nocar.plan.presentation.response; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.transit.domain.model.TransitInfo; -import lombok.Builder; - -import java.util.List; - -@Builder -public record PlanTravelCourseResponseDto( - String startPlaceName, - double startLongitude, - double startLatitude, - String endPlaceName, - double endLongitude, - double endLatitude, - int totalTime, - double totalDistance, - List subPaths -) { - - public static PlanTravelCourseResponseDto of( - Location fromLocation, - String fromName, - Location toLocation, - String toName, - TransitInfo transitInfo - ) { - return PlanTravelCourseResponseDto.builder() - .startPlaceName(fromName) - .startLongitude(fromLocation.getLongitude()) - .startLatitude(fromLocation.getLatitude()) - .endPlaceName(toName) - .endLongitude(toLocation.getLongitude()) - .endLatitude(toLocation.getLatitude()) - .totalTime(transitInfo.getTotalTime()) - .totalDistance(transitInfo.getTotalDistance()) - .subPaths(transitInfo.getSubPaths().stream() - .map(subPath -> - switch (subPath.getPathType()) { - case BUS -> BusPathResponseDto.of(subPath); - case WALK -> WalkPathResponseDto.of(subPath); - case SUBWAY -> SubwayPathResponseDto.of(subPath); - }) - .toList() - ) - .build(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDto.java b/src/main/java/com/example/mohago_nocar/plan/presentation/v1/PlanTravelCourseRequestDto.java similarity index 70% rename from src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDto.java rename to src/main/java/com/example/mohago_nocar/plan/presentation/v1/PlanTravelCourseRequestDto.java index ffaba01..5330fa7 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/request/PlanTravelCourseRequestDto.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/v1/PlanTravelCourseRequestDto.java @@ -1,4 +1,4 @@ -package com.example.mohago_nocar.plan.presentation.request; +package com.example.mohago_nocar.plan.presentation.v1; import io.swagger.v3.oas.annotations.media.Schema; @@ -16,7 +16,7 @@ public record PlanTravelCourseRequestDto( @Schema(description = "도착 시간", example = "10:00") LocalTime arrivalTime, - @Schema(description = "선택된 여행 장소들의 Id", example = "[1234]") - List travelPlaceIds + @Schema(description = "선택된 여행 장소들의 아이디", example = "[1234]") + List placeIds ) { } diff --git a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java b/src/main/java/com/example/mohago_nocar/plan/presentation/v1/TravelPlanControllerV1.java similarity index 53% rename from src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java rename to src/main/java/com/example/mohago_nocar/plan/presentation/v1/TravelPlanControllerV1.java index 7764aff..350fbf6 100644 --- a/src/main/java/com/example/mohago_nocar/plan/presentation/TravelPlanController.java +++ b/src/main/java/com/example/mohago_nocar/plan/presentation/v1/TravelPlanControllerV1.java @@ -1,9 +1,7 @@ -package com.example.mohago_nocar.plan.presentation; +package com.example.mohago_nocar.plan.presentation.v1; import com.example.mohago_nocar.global.common.response.ApiResponse; -import com.example.mohago_nocar.plan.domain.service.TravelPlanUseCase; -import com.example.mohago_nocar.plan.presentation.request.PlanTravelCourseRequestDto; -import com.example.mohago_nocar.plan.presentation.response.PlanTravelCourseResponseDto; +import com.example.mohago_nocar.plan.domain.service.TravelCoursePlanUseCaseV1; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -12,21 +10,23 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; +import java.util.concurrent.CompletableFuture; +@Deprecated @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/travel-plan") @Tag(name = "Plan", description = "여행 코스 설계") -public class TravelPlanController { +public class TravelPlanControllerV1 { - private final TravelPlanUseCase travelPlanUseCase; + private final TravelCoursePlanUseCaseV1 travelCoursePlanUseCaseV1; @PostMapping - public ApiResponse planTravelCourse( + public CompletableFuture> planTravelCourse( @RequestBody @Valid PlanTravelCourseRequestDto requestDto ) { - List responseDto = travelPlanUseCase.planCourse(requestDto); - return ApiResponse.ok(responseDto); + return travelCoursePlanUseCaseV1.planCourse(requestDto) + .thenApply(ApiResponse::ok); } + } diff --git a/src/main/java/com/example/mohago_nocar/transit/application/mapper/TransitMapper.java b/src/main/java/com/example/mohago_nocar/transit/application/mapper/TransitMapper.java deleted file mode 100644 index 54f96be..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/application/mapper/TransitMapper.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.example.mohago_nocar.transit.application.mapper; - -import com.example.mohago_nocar.transit.domain.model.*; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.RouteResponseDto; -import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; - -import java.util.List; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; - -@Slf4j -public class TransitMapper { - - public static TransitInfo mapRouteResponseDtoToTransitInfo(RouteResponseDto routeResponseDto) { - JsonNode path = extractPath(routeResponseDto); - - double totalDistance = extractTotalDistance(path); - int totalTime = extractTotalTime(path); - List subPaths = extractSubPaths(path); - - return TransitInfo.from(totalTime, totalDistance, subPaths); - } - - private static JsonNode extractPath(RouteResponseDto routeResponseDto) { - return routeResponseDto.result().get("path").get(0); - } - - private static double extractTotalDistance(JsonNode path) { - JsonNode infoNode = path.get("info"); - double distanceInMeters = infoNode.get("totalDistance").asDouble(); - - return distanceInMeters * 0.001; - } - - private static int extractTotalTime(JsonNode path) { - JsonNode infoNode = path.get("info"); - - return infoNode.get("totalTime").asInt(); - } - - private static List extractSubPaths(JsonNode path) { - JsonNode subPathNode = path.get("subPath"); - return convertPathesNodeToSubPaths(subPathNode); - } - - private static List convertPathesNodeToSubPaths(JsonNode subPathsNode) { - return streamJsonNodeOrEmpty(subPathsNode) - .map(TransitMapper::convertPathNodeToSubPath) - .toList(); - } - - private static SubPath convertPathNodeToSubPath(JsonNode subPathNode) { - double distance = (subPathNode.get("distance").asDouble()) * 0.001; - int sectionTime = subPathNode.get("sectionTime").asInt(); - int trafficType = subPathNode.get("trafficType").asInt(); - - return switch (trafficType) { - case 1 -> createSubwayPath(distance, sectionTime, subPathNode); - case 2 -> createBusPath(distance, sectionTime, subPathNode); - case 3 -> createWalkPath(distance, sectionTime); - default -> null; - }; - } - - private static SubPath createSubwayPath(double distance, int sectionTime, JsonNode subPathNode) { - String startSubwayStationName = subPathNode.get("startName").asText(); - String endSubWayStationName = subPathNode.get("endName").asText(); - - double startSubwayStationLongitude = subPathNode.get("startX").asDouble(); - double startSubwayStationLatitude = subPathNode.get("startY").asDouble(); - - double endSubwayStationLongitude = subPathNode.get("endX").asDouble(); - double endSubwayStationLatitude = subPathNode.get("endY").asDouble(); - - String subwayLineName = subPathNode.get("lane").get(0).get("name").asText(); - - return new SubwayPath( - distance, - sectionTime, - subwayLineName, - startSubwayStationName, - startSubwayStationLongitude, - startSubwayStationLatitude, - endSubWayStationName, - endSubwayStationLongitude, - endSubwayStationLatitude - ); - } - - private static SubPath createBusPath(double distance, int sectionTime, JsonNode subPathNode) { - String startBusStopName = subPathNode.get("startName").asText(); - String endBusStopName = subPathNode.get("endName").asText(); - - double startBusStopLongitude = subPathNode.get("startX").asDouble(); - double startBusStopLatitude = subPathNode.get("startY").asDouble(); - - double endBusStopLongitude = subPathNode.get("endX").asDouble(); - double endBusStopLatitude = subPathNode.get("endY").asDouble(); - - String busNo = subPathNode.get("lane").get(0).get("busNo").asText(); - int busType = subPathNode.get("lane").get(0).get("type").asInt(); - - return new BusPath( - distance, - sectionTime, - busNo, - busType, - startBusStopName, - startBusStopLongitude, - startBusStopLatitude, - endBusStopName, - endBusStopLongitude, - endBusStopLatitude - ); - } - - private static SubPath createWalkPath(double distance, int sectionTime) { - return new WalkPath(distance, sectionTime); - } - - private static Stream streamJsonNodeOrEmpty(JsonNode node) { - if (node == null || !node.isArray()) { - return Stream.empty(); - } - return StreamSupport.stream(node.spliterator(), false); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/application/service/TransitService.java b/src/main/java/com/example/mohago_nocar/transit/application/service/TransitService.java deleted file mode 100644 index fcc66a2..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/application/service/TransitService.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.example.mohago_nocar.transit.application.service; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.transit.application.mapper.TransitMapper; -import com.example.mohago_nocar.transit.domain.model.TransitInfo; -import com.example.mohago_nocar.transit.domain.service.TransitUseCase; -import com.example.mohago_nocar.transit.infrastructure.externalApi.ODsayApiClient; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.RouteResponseDto; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class TransitService implements TransitUseCase { - - private final ODsayApiClient oDsayApiClient; - - @Override - public TransitInfo findRouteTransitBetweenPlaces(Location from, Location to) { - RouteResponseDto response = oDsayApiClient.searchRoute( - from.getLongitude(), - from.getLatitude(), - to.getLongitude(), - to.getLatitude() - ); - - return TransitMapper.mapRouteResponseDtoToTransitInfo(response); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/applicatoin/TransitRouteSummaryUseCaseImpl.java b/src/main/java/com/example/mohago_nocar/transit/applicatoin/TransitRouteSummaryUseCaseImpl.java new file mode 100644 index 0000000..3f0d7b6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/applicatoin/TransitRouteSummaryUseCaseImpl.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.transit.applicatoin; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.domain.service.TransitRouteSummaryUseCase; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.DistanceDurationApiAdapter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +@Service +@RequiredArgsConstructor +public class TransitRouteSummaryUseCaseImpl implements TransitRouteSummaryUseCase { + + private final DistanceDurationApiAdapter apiAdapter; + + @Override + public CompletableFuture> getRouteSummary(Coordinate origin, Set destinations) { + for (Coordinate destination : destinations) { + if (origin.equals(destination)) { + throw new IllegalArgumentException("Origin and destination are equal"); + } + } + + return apiAdapter.getDistanceAndDurationAsync(origin, destinations); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java index 32e909f..e5eb722 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/BusPath.java @@ -1,62 +1,55 @@ package com.example.mohago_nocar.transit.domain.model; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; import static com.example.mohago_nocar.transit.domain.model.PathType.BUS; +import static jakarta.persistence.GenerationType.IDENTITY; @Getter +@NoArgsConstructor +@EqualsAndHashCode +@ToString(callSuper = true) public class BusPath extends SubPath{ - private final String busNo; // 버스 번호 - private final int busType; // 버스 타입 - private final String startName; // 출발 지점 이름 - private final double startX; // 출발 지점 X 좌표 - private final double startY; // 출발 지점 Y 좌표 - private final String endName; // 도착 지점 이름 - private final double endX; // 도착 지점 X 좌표 - private final double endY; // 도착 지점 Y 좌표 + private String busNo; // 버스 번호 + + private int busType; // 버스 타입 + + private String startName; // 출발 지점 이름 + + private Coordinate startCoordinate; + + private String endName; // 도착 지점 이름 + + private Coordinate endCoordinate; public BusPath( - double distance, - int sectionTime, + double distanceKm, + int timeTakenMin, String busNo, int busType, String startName, - double startX, - double startY, + Coordinate startCoordinate, String endName, - double endX, - double endY + Coordinate endCoordinate ) { - super(distance, sectionTime); + super(distanceKm, timeTakenMin, BUS); this.busNo = busNo; this.busType = busType; this.startName = startName; - this.startX = startX; - this.startY = startY; + this.startCoordinate = startCoordinate; this.endName = endName; - this.endX = endX; - this.endY = endY; + this.endCoordinate = endCoordinate; } - @Override - public PathType getPathType() { - return BUS; - } +// @Override +// public PathType getPathType() { +// return getPathType(); +// } - @Override - public String toString() { - return "BusPath{" + - "busNo='" + busNo + '\'' + - ", busType=" + busType + - ", startName='" + startName + '\'' + - ", startX=" + startX + - ", startY=" + startY + - ", endName='" + endName + '\'' + - ", endX=" + endX + - ", endY=" + endY + - ", distance=" + distance + - ", sectionTime=" + sectionTime + - '}'; - } } diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/RouteMetrics.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/RouteMetrics.java new file mode 100644 index 0000000..a293bea --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/RouteMetrics.java @@ -0,0 +1,39 @@ +package com.example.mohago_nocar.transit.domain.model; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import lombok.Builder; + +/** + * + * @param distanceInKm + * @param durationInMinutes + * @param origin + * @param destination + */ +@Builder +public record RouteMetrics( + Double distanceInKm, + Long durationInMinutes, + Coordinate origin, + Coordinate destination +) { + + public static RouteMetrics of( + GoogleDistanceMatrixResponse.Element element, + Coordinate origin, + Coordinate destination + ) { + return RouteMetrics.builder() + .distanceInKm(element.distance().value() / 1000.0) + .durationInMinutes(element.duration().value() / 60L) + .origin(origin) + .destination(destination) + .build(); + } + + public boolean isEqualLocation(Coordinate origin, Coordinate destination) { + return this.origin.equals(origin) && this.destination.equals(destination); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java index 6b78b71..b7dcf49 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubPath.java @@ -1,16 +1,40 @@ package com.example.mohago_nocar.transit.domain.model; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "pathType", + visible = true +) +@JsonSubTypes({ + @Type(value = BusPath.class, name = "BUS"), + @Type(value = SubwayPath.class, name = "SUBWAY"), + @Type(value = WalkPath.class, name = "WALK"), +}) @Getter +@NoArgsConstructor +@EqualsAndHashCode +@ToString public abstract class SubPath { - protected final double distance; // 구간 거리 - protected final int sectionTime; // 구간 소요 시간 - protected SubPath(double distance, int sectionTime) { - this.distance = distance; - this.sectionTime = sectionTime; + private double distanceKm; // 구간 거리 + + private int timeTakenMin; // 구간 소요 시간 + + private PathType pathType; + + protected SubPath(double distanceKm, int timeTakenMin, PathType pathType) { + this.distanceKm = distanceKm; + this.timeTakenMin = timeTakenMin; + this.pathType = pathType; } - public abstract PathType getPathType(); } diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java index fc83082..daf2092 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/SubwayPath.java @@ -1,57 +1,47 @@ package com.example.mohago_nocar.transit.domain.model; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; import static com.example.mohago_nocar.transit.domain.model.PathType.SUBWAY; +import static jakarta.persistence.GenerationType.IDENTITY; @Getter +@NoArgsConstructor +@EqualsAndHashCode +@ToString(callSuper = true) public class SubwayPath extends SubPath{ - private final String subwayLineName; // 지하철 노선명 - private final String startName; // 출발 지점 이름 - private final double startX; // 출발 지점 X 좌표 - private final double startY; // 출발 지점 Y 좌표 - private final String endName; // 도착 지점 이름 - private final double endX; // 도착 지점 X 좌표 - private final double endY; // 도착 지점 Y 좌표 + + private String subwayLineName; + + private String startName; + + private Coordinate startCoordinate; + + private String endName; + + private Coordinate endCoordinate; public SubwayPath( - double distance, - int sectionTime, + double distanceKm, + int timeTakenMin, String subwayLineName, String startName, - double startX, - double startY, + Coordinate startCoordinate, String endName, - double endX, - double endY + Coordinate endCoordinate ) { - super(distance, sectionTime); + super(distanceKm, timeTakenMin, SUBWAY); this.subwayLineName = subwayLineName; this.startName = startName; - this.startX = startX; - this.startY = startY; + this.startCoordinate = startCoordinate; this.endName = endName; - this.endX = endX; - this.endY = endY; + this.endCoordinate = endCoordinate; } - @Override - public PathType getPathType() { - return SUBWAY; - } - - @Override - public String toString() { - return "SubwayPath{" + - "subwayLineName='" + subwayLineName + '\'' + - ", startName='" + startName + '\'' + - ", startX=" + startX + - ", startY=" + startY + - ", endName='" + endName + '\'' + - ", endX=" + endX + - ", endY=" + endY + - ", distance=" + distance + - ", sectionTime=" + sectionTime + - '}'; - } } + diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitInfo.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitInfo.java deleted file mode 100644 index f5408f7..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitInfo.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.example.mohago_nocar.transit.domain.model; - -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; - -import java.util.List; - -@Getter -public class TransitInfo { - - private final int totalTime; - private final double totalDistance; - private final List subPaths; - - public static TransitInfo from(int totalTime, double totalDistance, List subPaths) { - return TransitInfo.builder() - .totalTime(totalTime) - .totalDistance(totalDistance) - .subPaths(subPaths) - .build(); - } - - @Builder - private TransitInfo(int totalTime, double totalDistance, List subPaths) { - this.totalTime = totalTime; - this.totalDistance = totalDistance; - this.subPaths = subPaths; - } - - @Override - public String toString() { - List collect = subPaths.stream().map(Object::toString).toList(); - - return "TransitInfo{" + - "totalDistance=" + totalDistance + - ", subPaths=" + collect + - '}'; - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java new file mode 100644 index 0000000..c073883 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/TransitRoute.java @@ -0,0 +1,61 @@ +package com.example.mohago_nocar.transit.domain.model; + +import com.example.mohago_nocar.plan.domain.model.Location; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +import static jakarta.persistence.GenerationType.IDENTITY; + +@NoArgsConstructor +@Getter +@ToString +public class TransitRoute { + + private int totalTime; + + private double totalDistance; + + private List subPaths; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "name", column = @Column(name = "start_name")), + @AttributeOverride(name = "coordinate.latitude", column = @Column(name = "start_latitude")), + @AttributeOverride(name = "coordinate.longitude", column = @Column(name = "start_longitude")) + }) + private Location origin; + + @Embedded + @AttributeOverrides({ + @AttributeOverride(name = "name", column = @Column(name = "end_name")), + @AttributeOverride(name = "coordinate.latitude", column = @Column(name = "end_latitude")), + @AttributeOverride(name = "coordinate.longitude", column = @Column(name = "end_longitude")) + }) + private Location destination; + + public static TransitRoute from(Location origin, Location destination, + int totalTime, double totalDistance, List subPaths) { + TransitRoute route = TransitRoute.builder() + .origin(origin) + .destination(destination) + .totalTime(totalTime) + .totalDistance(totalDistance) + .subPaths(subPaths) + .build(); + + return route; + } + + @Builder(access = AccessLevel.PRIVATE) + private TransitRoute(Location origin, Location destination, int totalTime, double totalDistance, List subPaths) { + this.origin = origin; + this.destination = destination; + this.totalTime = totalTime; + this.totalDistance = totalDistance; + this.subPaths = subPaths; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java b/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java index 30aa826..8947ff9 100644 --- a/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java +++ b/src/main/java/com/example/mohago_nocar/transit/domain/model/WalkPath.java @@ -1,24 +1,20 @@ package com.example.mohago_nocar.transit.domain.model; +import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import static jakarta.persistence.GenerationType.IDENTITY; @Getter +@NoArgsConstructor +@EqualsAndHashCode +@ToString(callSuper = true) public class WalkPath extends SubPath{ - public WalkPath(double distance, int sectionTime) { - super(distance, sectionTime); - } - - @Override - public PathType getPathType() { - return PathType.WALK; + public WalkPath(double distanceKm, int timeTakenMin) { + super(distanceKm, timeTakenMin, PathType.WALK); } - @Override - public String toString() { - return "WalkPath{" + - "distance=" + distance + - ", sectionTime=" + sectionTime + - '}'; - } } diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitRouteSummaryUseCase.java b/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitRouteSummaryUseCase.java new file mode 100644 index 0000000..05d80e3 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitRouteSummaryUseCase.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.transit.domain.service; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import org.springframework.util.RouteMatcher; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +public interface TransitRouteSummaryUseCase { + + /** + * 출발지와 목적지 리스트 내에 있는 각 목적지 사이의 이동 요약 정보를 구합니다. + * @param origin 출발지의 경위도 + * @param destinations 목적지 경위도 집합 + * @return 이동 시간 및 거리 + */ + CompletableFuture> getRouteSummary(Coordinate origin, Set destinations); + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitUseCase.java b/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitUseCase.java deleted file mode 100644 index b58fc35..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/domain/service/TransitUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.mohago_nocar.transit.domain.service; - -import com.example.mohago_nocar.global.common.domain.vo.Location; -import com.example.mohago_nocar.transit.domain.model.TransitInfo; - -public interface TransitUseCase { - - TransitInfo findRouteTransitBetweenPlaces(Location from, Location to); -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java new file mode 100644 index 0000000..89f60c9 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/DistanceDurationApiAdapter.java @@ -0,0 +1,16 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +// todo 이름 변경해도 될듯 +public interface DistanceDurationApiAdapter { + + List getDistanceAndDuration(Coordinate origin, List destinations); + + CompletableFuture> getDistanceAndDurationAsync(Coordinate origin, Set destinations); +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java new file mode 100644 index 0000000..51eedee --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapter.java @@ -0,0 +1,68 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.DistanceDurationConverter; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.GoogleApiClient; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.GoogleResponseValidator; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.error.code.GoogleDistanceMatrixErrorCode; +import com.example.mohago_nocar.transit.infrastructure.error.exception.DistanceMatrixException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +@Component +@RequiredArgsConstructor +@Slf4j +public class GoogleDistanceMatrixApiAdapter implements DistanceDurationApiAdapter { + + private final GoogleApiClient googleApiClient; + + @Override + public List getDistanceAndDuration(Coordinate origin, List destinations) { + GoogleDistanceMatrixResponse response = googleApiClient.getDistanceMatrix(origin, destinations); + + log.info("Distance matrix response: {}", response); + if (GoogleResponseValidator.hasError(response)) { + processInvalidResponse(response); + } + + return processValidResponse(origin, destinations, response); + } + + @Override + public CompletableFuture> getDistanceAndDurationAsync(Coordinate origin, Set destinations) { + CompletableFuture distanceMatrixFuture = + googleApiClient.getDistanceMatrixAsync(origin, destinations); + + return distanceMatrixFuture.thenApply(response -> { + if (GoogleResponseValidator.hasError(response)) { + processInvalidResponse(response); + } + + return processValidResponse(origin, destinations.stream().toList(), response); + }); + } + + private void processInvalidResponse(GoogleDistanceMatrixResponse response) { + switch (response.status()) { + case INVALID_REQUEST, MAX_ELEMENTS_EXCEEDED, MAX_DIMENSIONS_EXCEEDED, REQUEST_DENIED -> + throw new DistanceMatrixException(GoogleDistanceMatrixErrorCode.INVALID_REQUEST); + case OVER_DAILY_LIMIT, OVER_QUERY_LIMIT -> + throw new DistanceMatrixException(GoogleDistanceMatrixErrorCode.QUOTA_EXCEEDED); + default -> throw new DistanceMatrixException(GoogleDistanceMatrixErrorCode.SERVER_ERROR); + } + } + + private List processValidResponse( + Coordinate origin, List destinations, GoogleDistanceMatrixResponse response) { + return DistanceDurationConverter.convertMatrixToRouteMetrics(response, origin, destinations); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/DistanceDurationConverter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/DistanceDurationConverter.java new file mode 100644 index 0000000..2096f4c --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/DistanceDurationConverter.java @@ -0,0 +1,26 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; + +import java.util.List; +import java.util.stream.IntStream; + +public class DistanceDurationConverter { + + public static List convertMatrixToRouteMetrics( + GoogleDistanceMatrixResponse distanceMatrix, + Coordinate origin, + List destinations + ) { + + return IntStream.range(0, destinations.size()) + .mapToObj(visit -> RouteMetrics.of( + distanceMatrix.rows().getFirst().elements().get(visit), + origin, + destinations.get(visit))) + .toList(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java new file mode 100644 index 0000000..f5d391e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClient.java @@ -0,0 +1,107 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Component +public class GoogleApiClient { + + private final WebClient webClient; + private final String apiKey; + private final String baseUrl; + + private static final String DELIMITER_COMMA = "," ; + private static final String PIPE = "|" ; + + public GoogleApiClient( + @Value("${google.api-key}") String apiKey, + @Value("${google.maps.distance}") String baseUrl + ) { + this.webClient = WebClient.builder().build(); + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + /** + * 하나의 출발지와 여러 목적지로 이루어진 행렬을 반환합니다. + * 각 셀의 값은 `(거리, 소요 시간)` 형식입니다. + * + * + * + * + * + * + * + * + *
Destination 1 Destination 2 Destination 3
Origin 1 Value 1 Value 2 Value 3
+ * @return 행렬에 기반한 (출발지, 목적지)와 관련된 데이터를 반환합니다. + */ + public GoogleDistanceMatrixResponse getDistanceMatrix(Coordinate origin, List destinations) { + URI requestUri = buildUri(origin, destinations.stream()); + return executeApiCall(requestUri); + } + + public CompletableFuture getDistanceMatrixAsync(Coordinate origin, Set destinations) { + URI requestUri = buildUri(origin, destinations.stream()); + return executeApiCallAsync(requestUri); + } + + private URI buildUri(Coordinate origin, Stream destinations) { + return UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("origins", encodeCoordinate(origin)) + .queryParam("destinations", encodeCoordinates(destinations)) + .queryParam("language", "ko") + .queryParam("mode", "transit") + .queryParam("key", apiKey) + .build(true) + .toUri(); + } + + private CompletableFuture executeApiCallAsync(URI requestUri) { + return webClient.get() + .uri(requestUri) + .retrieve() + .bodyToMono(GoogleDistanceMatrixResponse.class) + .toFuture(); + } + + private GoogleDistanceMatrixResponse executeApiCall(URI requestUri) { + return webClient.get() + .uri(requestUri) + .retrieve() + .bodyToMono(GoogleDistanceMatrixResponse.class) + .block(); + } + + private String encodeCoordinate(Coordinate origin) { + return origin.getLatitude() + DELIMITER_COMMA + origin.getLongitude(); + } + + /** + * Coordinate 스트림을 "위도,경도|위도,경도|..." 형태로 변환 후 URL 인코딩합니다. + */ + private String encodeCoordinates(Stream coordinates) { + return URLEncoder.encode( + coordinates + .map(location -> location.getLatitude() + DELIMITER_COMMA + location.getLongitude()) + .collect(Collectors.joining(PIPE)), + StandardCharsets.UTF_8 + ); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleResponseValidator.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleResponseValidator.java new file mode 100644 index 0000000..43f807a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleResponseValidator.java @@ -0,0 +1,30 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google; + +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixStatus; + +import java.util.List; + +public class GoogleResponseValidator { + + public static boolean hasError(GoogleDistanceMatrixResponse response) { + return isInvalidStatus(response) || containsNullElements(response.rows()); + } + + private static boolean isInvalidStatus(GoogleDistanceMatrixResponse response) { + return response.status() != GoogleDistanceMatrixStatus.OK; + } + + private static boolean containsNullElements(List elements) { + if (elements == null) { + return true; + } + + return elements.get(0).elements().stream() + .anyMatch(element -> + element.distance() == null || element.distance().text() == null || + element.duration() == null || element.duration().text() == null + ); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/DistanceMatrixElementStatus.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/DistanceMatrixElementStatus.java new file mode 100644 index 0000000..e4a82b4 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/DistanceMatrixElementStatus.java @@ -0,0 +1,8 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response; + +public enum DistanceMatrixElementStatus { + OK, + NOT_FOUND, + ZERO_RESULTS, + MAX_ROUTE_LENGTH_EXCEEDED +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixResponse.java new file mode 100644 index 0000000..57d7d51 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixResponse.java @@ -0,0 +1,32 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response; + +import java.util.List; + +public record GoogleDistanceMatrixResponse( + List destination_addresses, + List origin_addresses, + List rows, + GoogleDistanceMatrixStatus status +) { + + public record Row( + List elements + ){} + + public record Element( + Distance distance, + Duration duration, + DistanceMatrixElementStatus status + ){} + + public record Distance( + String text, + Long value + ){} + + public record Duration( + String text, + Long value + ){} + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixStatus.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixStatus.java new file mode 100644 index 0000000..77d9cd6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/dto/response/GoogleDistanceMatrixStatus.java @@ -0,0 +1,19 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum GoogleDistanceMatrixStatus { + + OK("valid result"), + INVALID_REQUEST("provided request was invalid"), + MAX_ELEMENTS_EXCEEDED("product of origins and destinations exceeds the per-query limit"), + MAX_DIMENSIONS_EXCEEDED("number of origins or destinations exceeds the per-query limit"), + OVER_DAILY_LIMIT("any of the following: 1. The API key is missing or invalid | 2. Billing has not been enabled on your account | 3. self-imposed usage cap has been exceeded | 4. The provided method of payment is no longer valid"), + OVER_QUERY_LIMIT("service has received too many requests from your application within the allowed time period"), + REQUEST_DENIED("service denied use of the Distance Matrix service by your application"), + UNKNOWN_ERROR("Distance Matrix request could not be processed due to a server error"); + + private final String message; + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/GoogleDistanceMatrixErrorCode.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/GoogleDistanceMatrixErrorCode.java new file mode 100644 index 0000000..acae0d5 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/GoogleDistanceMatrixErrorCode.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.transit.infrastructure.error.code; + +import com.example.mohago_nocar.global.common.exception.Status; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GoogleDistanceMatrixErrorCode implements Status { + + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "GOOGLE400", "잘못된 요청입니다"), + QUOTA_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "GOOGLE429", "할당량이 초과되었습니다"), + SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GOOGLE500", "외부 서버 오류가 발생했습니다"), + API_KEY_INVALID(HttpStatus.UNAUTHORIZED, "GOOGLE401 ", "유효하지 않은 API 키입니다"); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java index 20b511c..797626c 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/code/OdsayErrorCode.java @@ -1,7 +1,8 @@ package com.example.mohago_nocar.transit.infrastructure.error.code; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import com.example.mohago_nocar.global.common.exception.InternalServerException; import com.example.mohago_nocar.global.common.exception.Status; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayException; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; @@ -24,7 +25,10 @@ public enum OdsayErrorCode implements Status { ARRIVAL_POINT_MISSING(HttpStatus.NOT_FOUND, "ODSAY404", "도착지 정류장이 없습니다."), START_ARRIVAL_POINTS_MISSING(HttpStatus.NOT_FOUND, "ODSAY404", "출발지, 도착지 정류장이 없습니다."), SERVICE_AREA_NOT_AVAILABLE(HttpStatus.NOT_FOUND, "ODSAY404", "서비스 지역이 아닙니다."), - NO_SEARCH_RESULTS(HttpStatus.NOT_FOUND, "ODSAY404", "길찾기 검색결과가 없습니다.") + NO_SEARCH_RESULTS(HttpStatus.NOT_FOUND, "ODSAY404", "길찾기 검색결과가 없습니다."), + + // TOO_MANY_REQUESTS + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "ODSAY429", "Too Many Requests"), ; private final HttpStatus httpStatus; @@ -43,11 +47,25 @@ public static OdsayErrorCode from(String code) { case "-98" -> POINTS_WITHIN_DISTANCE; case "-99" -> NO_SEARCH_RESULTS; case "-1" -> COMPONENT_ERROR; - default -> throw new OdsayException("unknown Error Code 발생 : "+ code, ODSAY_SERVER_ERROR); + case "429" -> TOO_MANY_REQUESTS; + default -> throw new InternalServerException("unknown Error Code 발생 : "+ code); }; } - public boolean isDistanceException() { + public boolean isDistanceError() { return this == POINTS_WITHIN_DISTANCE; } + + public boolean isServerError() { + return this == ODSAY_SERVER_ERROR; + } + + public boolean isTooManyRequests() { + return this == TOO_MANY_REQUESTS; + } + + public boolean isUnExpectedError() { + return this == REQUIRED_INPUT_FORMAT_ERROR || this == REQUIRED_INPUT_MISSING || this == COMPONENT_ERROR; + } + } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/DistanceMatrixException.java similarity index 55% rename from src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayException.java rename to src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/DistanceMatrixException.java index 5562b47..1928d95 100644 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayException.java +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/DistanceMatrixException.java @@ -3,13 +3,10 @@ import com.example.mohago_nocar.global.common.exception.CustomException; import com.example.mohago_nocar.global.common.exception.Status; -public class OdsayException extends CustomException { +public class DistanceMatrixException extends CustomException { - public OdsayException(Status status) { + public DistanceMatrixException(Status status) { super(status); } - public OdsayException(String message, Status status) { - super(message, status); - } } diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayDistanceException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayDistanceException.java new file mode 100644 index 0000000..af53f17 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayDistanceException.java @@ -0,0 +1,12 @@ +package com.example.mohago_nocar.transit.infrastructure.error.exception; + +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.Status; + +public class ODsayDistanceException extends CustomException { + + public ODsayDistanceException(Status status) { + super(status); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java new file mode 100644 index 0000000..294c86a --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/ODsayRouteException.java @@ -0,0 +1,16 @@ +package com.example.mohago_nocar.transit.infrastructure.error.exception; + +import com.example.mohago_nocar.global.common.exception.Status; +import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; +import lombok.Getter; + +@Getter +public class ODsayRouteException extends RuntimeException { + + private final OdsayErrorCode errorCode; + + public ODsayRouteException(OdsayErrorCode errorCode) { + this.errorCode = errorCode; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayDistanceException.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayDistanceException.java deleted file mode 100644 index 63f5c91..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/error/exception/OdsayDistanceException.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.error.exception; - -import com.example.mohago_nocar.global.common.exception.CustomException; - -import static com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode.POINTS_WITHIN_DISTANCE; - -public class OdsayDistanceException extends CustomException { - - public OdsayDistanceException() { - super(POINTS_WITHIN_DISTANCE); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/ODsayApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/ODsayApiClient.java deleted file mode 100644 index 2703c25..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/ODsayApiClient.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi; - -import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayException; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.OdsaySearchRouteResponseDto; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.RouteResponseDto; -import com.example.mohago_nocar.transit.infrastructure.mapper.OdsayRouteMapper; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; -import org.springframework.web.util.UriComponentsBuilder; -import java.io.UnsupportedEncodingException; -import java.net.URI; -import java.net.URLEncoder; -import java.util.Objects; - -import static com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode.ODSAY_SERVER_ERROR; - -@Component -public class ODsayApiClient { - - private final RestClient restClient; - private final String apiKey; - private final String baseUrl; - - public static final int SLEEP_DURATION_MS = 200; - - public ODsayApiClient( - RestClient.Builder restClientBuilder, - @Value("${odsay.api-key}") String apiKey, - @Value("${odsay.url}") String baseUrl) { - this.restClient = restClientBuilder.build(); - this.apiKey = apiKey; - this.baseUrl = baseUrl; - } - - public RouteResponseDto searchRoute(double startX, double startY, double endX, double endY) { - URI requestURI = buildRequestURI(startX, startY, endX, endY); - OdsaySearchRouteResponseDto odsayRouteResponse = fetchOdsayRouteResponse(requestURI); - - return OdsayRouteMapper.mapOdsayRouteResponseToRouteResponse(odsayRouteResponse); - } - - private URI buildRequestURI(double startX, double startY, double endX, double endY) { - String encodedApiKey = createEncodedApiKey(); - - return UriComponentsBuilder.fromUriString(baseUrl) - .queryParam("SX", startX) - .queryParam("SY", startY) - .queryParam("EX", endX) - .queryParam("EY", endY) - .queryParam("apiKey", encodedApiKey) - .build(true) - .toUri(); - } - - private String createEncodedApiKey() { - try { - return URLEncoder.encode(apiKey, "UTF-8"); - - } catch (UnsupportedEncodingException e) { - throw new OdsayException(e.getMessage(), ODSAY_SERVER_ERROR); - } - } - - private OdsaySearchRouteResponseDto fetchOdsayRouteResponse(URI requestURI) { - try { - Thread.sleep(SLEEP_DURATION_MS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new OdsayException("Thread interrupted while sleeping", ODSAY_SERVER_ERROR); - } - - OdsaySearchRouteResponseDto response = restClient.get() - .uri(requestURI) - .retrieve() - .body(OdsaySearchRouteResponseDto.class); - - return Objects.requireNonNullElseGet(response, () -> { - throw new OdsayException(ODSAY_SERVER_ERROR); - }); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/OdsaySearchRouteResponseDto.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/OdsaySearchRouteResponseDto.java deleted file mode 100644 index bd86836..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/OdsaySearchRouteResponseDto.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response; - -import com.fasterxml.jackson.databind.JsonNode; - -import java.util.Optional; - -public record OdsaySearchRouteResponseDto( - Optional error, - Optional result -) { -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/RouteResponseDto.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/RouteResponseDto.java deleted file mode 100644 index 77d002f..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/externalApi/dto/response/RouteResponseDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response; - -import com.fasterxml.jackson.databind.JsonNode; -import lombok.Builder; - -@Builder -public record RouteResponseDto( - JsonNode result -) { - public static RouteResponseDto of(JsonNode result) { - return RouteResponseDto.builder() - .result(result) - .build(); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayResponseDeserializer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayResponseDeserializer.java deleted file mode 100644 index fc6a1aa..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayResponseDeserializer.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.mapper; - -import com.example.mohago_nocar.global.common.exception.InternalServerException; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.OdsaySearchRouteResponseDto; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.jackson.JsonComponent; -import java.io.IOException; -import java.util.Optional; - -@JsonComponent -@Slf4j -public class OdsayResponseDeserializer extends JsonDeserializer { - - @Override - public OdsaySearchRouteResponseDto deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) { - try { - JsonNode node = jsonParser.getCodec().readTree(jsonParser); - - return parse(node); - } catch (IOException exception) { - log.error("IOException 발생"); - throw new InternalServerException(exception.getMessage()); - } - } - - private OdsaySearchRouteResponseDto parse(JsonNode node) { - Optional errorNode = find(node, "error"); - Optional result = find(node, "result"); - - return new OdsaySearchRouteResponseDto(errorNode, result); - } - - private Optional find(JsonNode node, String fieldName) { - try { - JsonNode result = node.get(fieldName); - return Optional.of(result); - } catch (NullPointerException exception) { - return Optional.empty(); - } - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayRouteMapper.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayRouteMapper.java deleted file mode 100644 index 98bf99c..0000000 --- a/src/main/java/com/example/mohago_nocar/transit/infrastructure/mapper/OdsayRouteMapper.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.example.mohago_nocar.transit.infrastructure.mapper; - -import com.example.mohago_nocar.global.common.exception.InternalServerException; -import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayDistanceException; -import com.example.mohago_nocar.transit.infrastructure.error.exception.OdsayException; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.OdsaySearchRouteResponseDto; -import com.example.mohago_nocar.transit.infrastructure.externalApi.dto.response.RouteResponseDto; -import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; - -import java.util.Arrays; - - -@Slf4j -public class OdsayRouteMapper { - - public static RouteResponseDto mapOdsayRouteResponseToRouteResponse(OdsaySearchRouteResponseDto response) { - checkForOdsayErrors(response); - - return response.result() - .map(RouteResponseDto::of) - .orElseThrow(() -> new InternalServerException("ODsay API Result is missing")); - } - - private static void checkForOdsayErrors(OdsaySearchRouteResponseDto response) { - response.error().ifPresent(errorNode -> { - String errorCode = extractErrorInfo(errorNode, "code"); - String errorMessage = extractErrorInfo(errorNode, "message", "msg"); - - if (errorNode == null) { - log.error("에러 응답 바인딩을 실패하였습니다."); - log.warn("error : {}", errorNode.toPrettyString()); - throw new InternalServerException("ODsay API error 처리에 실패하였습니다."); - } - - OdsayErrorCode odsayErrorCode = OdsayErrorCode.from(errorCode); - - log.info("ODsay errorMessage: {}", errorMessage); - log.warn("ODsay API returns error response : {}", odsayErrorCode); - - if (odsayErrorCode.isDistanceException()) { - throw new OdsayDistanceException(); - } - - throw new OdsayException(odsayErrorCode); - }); - } - - private static String extractErrorInfo(JsonNode errorNode, String... fields) { - return Arrays.stream(fields) - .map(errorNode::findPath) - .filter(node -> !node.isMissingNode()) - .findFirst() - .map(JsonNode::asText) - .orElse(null); - } -} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java new file mode 100644 index 0000000..5fcf3c7 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/TransitRouteApiAdapter.java @@ -0,0 +1,14 @@ +package com.example.mohago_nocar.transit.infrastructure.route; + +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; + +import java.util.concurrent.CompletableFuture; + +public interface TransitRouteApiAdapter { + + TransitRoute getTransitRouteBetweenLocations(Location origin, Location destination); + + CompletableFuture getTransitRouteWithThrottling(Location origin, Location destination); + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java new file mode 100644 index 0000000..314fc19 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClient.java @@ -0,0 +1,95 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.global.rateLimit.RateLimiter; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +// todo 클래스명 수정 +@Component +@Slf4j +public class ODsayApiRateLimitedClient { + + private final OdsayApiProperties apiProperties; + private final RateLimiter rateLimiter; + private final WebClient webClient; + + public ODsayApiRateLimitedClient( + OdsayApiProperties apiProperties + ) { + this.apiProperties = apiProperties; + this.rateLimiter = RateLimiter.create(apiProperties.getExecutionIntervalMills()); + this.webClient = WebClient.builder().build(); + } + + /** + * 대중교통 경로를 검색합니다. + * + *

Rate Limit 제어: API 키 획득 시 rate limit 준수를 위해 + * 의도적으로 블로킹하여 소비 속도를 조절합니다.

+ * + * @param origin 출발지 좌표 + * @param destination 도착지 좌표 + * @return 경로 검색 결과의 CompletableFuture + */ + public ODsayTransitRouteResponse searchTransitRoute(Coordinate origin, Coordinate destination) { + URI requestURI = buildRequestURI(origin, destination); + rateLimiter.throttle(); + return executeApiCall(requestURI); + } + + /** + * 대중교통 경로를 검색합니다. API 응답을 비동기로 처리합니다. + * + *

Rate Limit 제어: API 키 획득 시 rate limit 준수를 위해 + * 의도적으로 블로킹하여 소비 속도를 조절합니다.

+ * + * @param origin 출발지 좌표 + * @param destination 도착지 좌표 + * @return 경로 검색 결과의 CompletableFuture + */ + public CompletableFuture searchTransitRouteAsync( + Coordinate origin, Coordinate destination) { + URI requestURI = buildRequestURI(origin, destination); + rateLimiter.throttle(); + return executeApiCallAsync(requestURI); + } + + private URI buildRequestURI(Coordinate origin, Coordinate destination) { + UriComponentsBuilder uriComponentsBuilder = + UriComponentsBuilder.fromUriString(apiProperties.getRequestUrl()) + .queryParam("SX", origin.getLongitude()) + .queryParam("SY", origin.getLatitude()) + .queryParam("EX", destination.getLongitude()) + .queryParam("EY", destination.getLatitude()); + + return uriComponentsBuilder + .queryParam("apiKey", apiProperties.getEncodedKey()) + .build(true) + .toUri(); + } + + private ODsayTransitRouteResponse executeApiCall(URI requestURI) { + return webClient.get() + .uri(requestURI) + .retrieve() + .bodyToMono(ODsayTransitRouteResponse.class) + .block(); + } + + private CompletableFuture executeApiCallAsync(URI requestURI) { + return webClient.get() + .uri(requestURI) + .retrieve() + .bodyToMono(ODsayTransitRouteResponse.class) + .toFuture(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java new file mode 100644 index 0000000..7e35f30 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayTransitRouteApiAdapter.java @@ -0,0 +1,114 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import com.example.mohago_nocar.transit.domain.model.WalkPath; +import com.example.mohago_nocar.transit.infrastructure.error.code.OdsayErrorCode; +import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; +import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayDistanceException; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteValidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.TransitRouteConverter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ODsayTransitRouteApiAdapter implements TransitRouteApiAdapter { + + private static final int EARTH_RADIUS = 6371; + + private final ODsayApiRateLimitedClient rateLimitedClient; + + @Override + public TransitRoute getTransitRouteBetweenLocations(Location origin, Location destination) { + ODsayTransitRouteResponse response = rateLimitedClient.searchTransitRoute(origin.getCoordinate(), destination.getCoordinate()); + if (!response.isValid()) { + try { + processInvalidResponse((ODsayRouteInvalidResponse)response); + } catch (ODsayDistanceException e) { + return createShortDistanceResponse(origin, destination); + } + } + + return processValidResponse(origin, destination, response); + } + + @Override + public CompletableFuture getTransitRouteWithThrottling(Location origin, Location destination) { + CompletableFuture future = + rateLimitedClient.searchTransitRouteAsync(origin.getCoordinate(), destination.getCoordinate()); + + return future.thenApply(response -> { + if (!response.isValid()) { + try { + processInvalidResponse((ODsayRouteInvalidResponse)response); + } catch (ODsayDistanceException e) { + return createShortDistanceResponse(origin, destination); + } + } + + return processValidResponse(origin, destination, response); + }); + } + + private void processInvalidResponse(ODsayRouteInvalidResponse response) { + log.warn("ODsay Invalid response: {}", response); + + OdsayErrorCode errorCode = OdsayErrorCode.from(response.getErrorCode()); + if (errorCode.isDistanceError()) { + throw new ODsayDistanceException(errorCode); + } + + throw new ODsayRouteException(errorCode); + } + + private TransitRoute processValidResponse(Location origin, Location destination, ODsayTransitRouteResponse response) { + log.info("Odsay API response: {}", response); + return TransitRouteConverter.convertToTransitRoute((ODsayRouteValidResponse) response, origin, destination); + } + + private TransitRoute createShortDistanceResponse(Location origin, Location destination) { + double walkingDistance = getKmDist(origin.getCoordinate(), destination.getCoordinate()); + int walkingTime = (int) Math.round(walkingDistance * 15); + WalkPath walkPath = new WalkPath(walkingDistance, walkingTime); + + return TransitRoute.from(origin, destination, walkingTime, walkingDistance, List.of(walkPath)); + } + + + /** + * 두 위치(Location) 사이의 거리를 킬로미터 단위로 계산합니다. + * @param departure + * @param arrival + * @return 두 위치(Location) 사이의 거리 + */ + private Double getKmDist(Coordinate departure, Coordinate arrival) { + Double dx = Math.abs(departure.getLongitude() - arrival.getLongitude()); + dx = Math.min(dx, 360 - dx); + + Double dy = Math.abs(departure.getLatitude() - arrival.getLatitude()); + + Double longitudeDist = convertLongitudeToKmDist(dx, departure.getLatitude()); + Double latitudeDist = convertLatitudeToKmDist(dy); + + return Math.sqrt(longitudeDist * longitudeDist + latitudeDist * latitudeDist); + } + + private Double convertLongitudeToKmDist(Double dx, Double stdLatitude) { + return EARTH_RADIUS * dx * Math.cos(Math.toRadians(stdLatitude)) * Math.PI / 180; + } + + private Double convertLatitudeToKmDist(Double dy) { + return EARTH_RADIUS * dy * Math.PI / 180; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiProperties.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiProperties.java new file mode 100644 index 0000000..fa81e30 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/OdsayApiProperties.java @@ -0,0 +1,25 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Getter +@ConfigurationProperties("odsay") +public class OdsayApiProperties { + + private final String requestUrl; + private final String key; + private final String encodedKey; + private final int executionIntervalMills; + + public OdsayApiProperties(String requestUrl, String key, int executionIntervalMills) { + this.requestUrl = requestUrl; + this.key = key; + this.encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8); + this.executionIntervalMills = executionIntervalMills; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayApiClient.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayApiClient.java new file mode 100644 index 0000000..b32d90d --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayApiClient.java @@ -0,0 +1,73 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.deprecated; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +// todo 클래스명 수정 +@Deprecated +@Component +@Slf4j +public class ODsayApiClient { + + private final WebClient webClient; + private final String baseUrl; + private final String apiKey; + + public ODsayApiClient( + @Value("${odsay.request-url}") String baseUrl, + @Value("${odsay.key}") String apiKey + ) { + this.webClient = WebClient.builder().build(); + this.baseUrl = baseUrl; + this.apiKey = apiKey; + } + + /** + * 대중교통 경로를 검색합니다. + * + *

Rate Limit 제어: API 키 획득 시 rate limit 준수를 위해 + * 의도적으로 블로킹하여 소비 속도를 조절합니다.

+ * + * @param origin 출발지 좌표 + * @param destination 도착지 좌표 + * @return 경로 검색 결과의 CompletableFuture + */ + public ODsayTransitRouteResponse searchTransitRoute(Coordinate origin, Coordinate destination) { + String encodedKey = URLEncoder.encode(apiKey, StandardCharsets.UTF_8); + URI requestURI = buildRequestURI(origin, destination, encodedKey); + ODsayTransitRouteResponse response = executeApiCall(requestURI); + return response; + } + + private ODsayTransitRouteResponse executeApiCall(URI requestURI) { + return webClient.get() + .uri(requestURI) + .retrieve() + .bodyToMono(ODsayTransitRouteResponse.class) + .block(); + } + + private URI buildRequestURI(Coordinate origin, Coordinate destination, String encodedKey) { + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("SX", origin.getLongitude()) + .queryParam("SY", origin.getLatitude()) + .queryParam("EX", destination.getLongitude()) + .queryParam("EY", destination.getLatitude()); + + return uriComponentsBuilder + .queryParam("apiKey", encodedKey) + .build(true) + .toUri(); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayTransitRouteApiExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayTransitRouteApiExecutor.java new file mode 100644 index 0000000..ecb3234 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/ODsayTransitRouteApiExecutor.java @@ -0,0 +1,84 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.deprecated; + +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import com.example.mohago_nocar.transit.infrastructure.route.TransitRouteApiAdapter; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +@Deprecated +@Component +@Slf4j +@RequiredArgsConstructor +public class ODsayTransitRouteApiExecutor implements TransitRouteApiExecutor { + + private final TransitRouteApiAdapter transitRouteApiAdapter; + private ExecutorService singleThreadExecutor; + + @PostConstruct + private void init() { + this.singleThreadExecutor = Executors.newSingleThreadExecutor(); + } + + @Override + public CompletableFuture> execute(final List locations) { + return CompletableFuture.supplyAsync(() -> { + validateLocations(locations); + return fetchTransitRoutes(locations); + }, singleThreadExecutor); + } + + private void validateLocations(List locations) { + if (locations == null || locations.size() < 2) { + throw new IllegalArgumentException("최소 2개 이상의 위치가 필요합니다."); + } + } + + private List fetchTransitRoutes(List locations) { + List routes = new ArrayList<>(); + + for (int i = 0; i < locations.size() - 1; i++) { + Location origin = locations.get(i); + Location destination = locations.get(i + 1); + + TransitRoute route = transitRouteApiAdapter.getTransitRouteBetweenLocations(origin, destination); + routes.add(route); + } + + return routes; + } + + @PreDestroy + private void shutdown() { + if (singleThreadExecutor == null) { + return; + } + + log.info("Single thread executor shutdown started"); + singleThreadExecutor.shutdown(); + try { + if (!singleThreadExecutor.awaitTermination(20, TimeUnit.SECONDS)) { + log.warn("정상 종료에 실패했습니다. 강제 종료를 시작합니다."); + singleThreadExecutor.shutdownNow(); + if (!singleThreadExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + log.error("강제 종료에 실패하였습니다."); + } + } + } catch (InterruptedException e) { + log.error("인터럽트 발생", e); + singleThreadExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/TransitRouteApiExecutor.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/TransitRouteApiExecutor.java new file mode 100644 index 0000000..84bcb06 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/deprecated/TransitRouteApiExecutor.java @@ -0,0 +1,14 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.deprecated; + +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Deprecated +public interface TransitRouteApiExecutor { + + CompletableFuture> execute(final List locations); + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteInvalidResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteInvalidResponse.java new file mode 100644 index 0000000..b563792 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteInvalidResponse.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.response; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@RequiredArgsConstructor +@Getter +@ToString +public class ODsayRouteInvalidResponse extends ODsayTransitRouteResponse { + + private final String errorCode; + private final String errorMessage; + + @Override + public Boolean isValid() { + return false; + } + +} + diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteValidResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteValidResponse.java new file mode 100644 index 0000000..3dc8f7e --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayRouteValidResponse.java @@ -0,0 +1,49 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.response; + +import lombok.*; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ToString +public class ODsayRouteValidResponse extends ODsayTransitRouteResponse { + + private final int totalTime; + private final double totalDistance; + + @Getter + @RequiredArgsConstructor + @Builder + @ToString + public static class SubPath { + + private final int trafficType; + private final double distanceMeter; + private final int sectionTimeMin; + + // common fields of types bus, subway + private final String startName; + private final Double startLongitude; + private final Double startLatitude; + private final String endName; + private final Double endLongitude; + private final Double endLatitude; + + // fields for bus + private final String busNo; + private final Integer busType; + + // fields for subway + private final String subwayLineName; + + } + + private final List subPaths; + + @Override + public Boolean isValid() { + return true; + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponse.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponse.java new file mode 100644 index 0000000..78531f6 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponse.java @@ -0,0 +1,10 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.response; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(using = ODsayTransitRouteResponseDeserializer.class) +public abstract class ODsayTransitRouteResponse { + + public abstract Boolean isValid(); + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponseDeserializer.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponseDeserializer.java new file mode 100644 index 0000000..6feb882 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/ODsayTransitRouteResponseDeserializer.java @@ -0,0 +1,164 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.response; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.jackson.JsonComponent; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +@JsonComponent +@Slf4j +public class ODsayTransitRouteResponseDeserializer extends JsonDeserializer { + + private static final int SUBWAY = 1; + private static final int BUS = 2; + private static final int WALKING = 3; + + @Override + public ODsayTransitRouteResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException { + JsonNode responseJson = parseJsonResponse(jsonParser); + + return isInvalidResponse(responseJson) ? + createInvalidResponse(responseJson) : + createValidResponse(responseJson); + } + + private ODsayTransitRouteResponse createInvalidResponse(JsonNode responseJson) { + log.info(responseJson.asText()); + var errorInfoJson = find(responseJson, "error").get(); + var errorCode = getFields(errorInfoJson, "code"); + var errorMessage = getFields(errorInfoJson, "message", "msg"); + + return new ODsayRouteInvalidResponse(errorCode, errorMessage); + } + + private ODsayTransitRouteResponse createValidResponse(JsonNode responseJson) { + var pathJson = responseJson.get("result").get("path").get(0); + var infoJson = pathJson.get("info"); + + var totalDistance = infoJson.get("totalDistance").asDouble(); + var totalTime = infoJson.get("totalTime").asInt(); + + var subPathsJson = pathJson.get("subPath"); + var subPaths = streamJsonNodeOrEmpty(subPathsJson) + .map(this::parseByPathType) + .toList(); + + return new ODsayRouteValidResponse(totalTime, totalDistance, subPaths); + } + + private ODsayRouteValidResponse.SubPath parseByPathType(JsonNode pathJson) { + var distance = (pathJson.get("distance").asDouble()); + var sectionTime = pathJson.get("sectionTime").asInt(); + var trafficType = pathJson.get("trafficType").asInt(); + + return switch (trafficType) { + case SUBWAY -> createSubwayPath(distance, sectionTime, pathJson); + case BUS -> createBusPath(distance, sectionTime, pathJson); + case WALKING -> createWalkPath(distance, sectionTime); + default -> throw new IllegalStateException("Unexpected traffic type: " + trafficType); + }; + } + + private ODsayRouteValidResponse.SubPath createSubwayPath(double distance, int sectionTime, JsonNode pathJson) { + String startSubwayStationName = pathJson.get("startName").asText(); + double startSubwayStationLongitude = pathJson.get("startX").asDouble(); + double startSubwayStationLatitude = pathJson.get("startY").asDouble(); + + String endSubWayStationName = pathJson.get("endName").asText(); + double endSubwayStationLongitude = pathJson.get("endX").asDouble(); + double endSubwayStationLatitude = pathJson.get("endY").asDouble(); + + String subwayLineName = pathJson.get("lane").get(0).get("name").asText(); + + return ODsayRouteValidResponse.SubPath.builder() + .trafficType(SUBWAY) + .distanceMeter(distance) + .sectionTimeMin(sectionTime) + .startName(startSubwayStationName) + .startLongitude(startSubwayStationLongitude) + .startLatitude(startSubwayStationLatitude) + .endName(endSubWayStationName) + .endLongitude(endSubwayStationLongitude) + .endLatitude(endSubwayStationLatitude) + .subwayLineName(subwayLineName) + .build(); + } + + private ODsayRouteValidResponse.SubPath createBusPath(double distance, int sectionTime, JsonNode pathJson) { + String startBusStopName = pathJson.get("startName").asText(); + double startBusStopLongitude = pathJson.get("startX").asDouble(); + double startBusStopLatitude = pathJson.get("startY").asDouble(); + + String endBusStopName = pathJson.get("endName").asText(); + double endBusStopLongitude = pathJson.get("endX").asDouble(); + double endBusStopLatitude = pathJson.get("endY").asDouble(); + + String busNo = pathJson.get("lane").get(0).get("busNo").asText(); + int busType = pathJson.get("lane").get(0).get("type").asInt(); + + return ODsayRouteValidResponse.SubPath.builder() + .trafficType(BUS) + .distanceMeter(distance) + .sectionTimeMin(sectionTime) + .startName(startBusStopName) + .startLongitude(startBusStopLongitude) + .startLatitude(startBusStopLatitude) + .endName(endBusStopName) + .endLongitude(endBusStopLongitude) + .endLatitude(endBusStopLatitude) + .busNo(busNo) + .busType(busType) + .build(); + } + + private ODsayRouteValidResponse.SubPath createWalkPath(double distance, int sectionTime) { + return ODsayRouteValidResponse.SubPath.builder() + .trafficType(WALKING) + .distanceMeter(distance) + .sectionTimeMin(sectionTime) + .build(); + } + + private Stream streamJsonNodeOrEmpty(JsonNode node) { + if (node == null || !node.isArray()) { + return Stream.empty(); + } + return StreamSupport.stream(node.spliterator(), false); + } + + private boolean isInvalidResponse(JsonNode responseJson) { + return find(responseJson, "error").isPresent(); + } + + private JsonNode parseJsonResponse(JsonParser jsonParser) throws IOException { + return jsonParser.getCodec().readTree(jsonParser); + } + + private Optional find(JsonNode node, String fieldName) { + try { + JsonNode result = node.get(fieldName); + return Optional.of(result); + } catch (NullPointerException exception) { + return Optional.empty(); + } + } + + private String getFields(JsonNode jsonNode, String... fields) { + return Arrays.stream(fields) + .map(jsonNode::findPath) + .filter(node -> !node.isMissingNode()) + .findFirst() + .map(JsonNode::asText) + .orElse(null); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/TransitRouteConverter.java b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/TransitRouteConverter.java new file mode 100644 index 0000000..192569b --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/response/TransitRouteConverter.java @@ -0,0 +1,59 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay.response; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.*; + +import java.util.List; + +public class TransitRouteConverter { + + private static final double METER_TO_KILOMETER = 0.001; + private static final int SUBWAY = 1; + private static final int BUS = 2; + private static final int WALKING = 3; + + public static TransitRoute convertToTransitRoute(ODsayRouteValidResponse response, Location origin, Location destination) { + List subPaths = response.getSubPaths().stream() + .map(path -> + switch (path.getTrafficType()) { + case SUBWAY -> createSubWay(path); + case BUS -> createBus(path); + case WALKING -> createWalking(path); + default -> throw new IllegalStateException("Unexpected value: " + path.getTrafficType()); + } + ).toList(); + + return TransitRoute.from(origin, destination, response.getTotalTime(), response.getTotalDistance(), subPaths); + } + + private static SubPath createSubWay(ODsayRouteValidResponse.SubPath path) { + return new SubwayPath( + path.getDistanceMeter() * METER_TO_KILOMETER, + path.getSectionTimeMin(), + path.getSubwayLineName(), + path.getStartName(), + Coordinate.from(path.getStartLongitude(), path.getStartLatitude()), + path.getEndName(), + Coordinate.from(path.getEndLongitude(), path.getEndLatitude()) + ); + } + + private static SubPath createBus(ODsayRouteValidResponse.SubPath path) { + return new BusPath( + path.getDistanceMeter() * METER_TO_KILOMETER, + path.getSectionTimeMin(), + path.getBusNo(), + path.getBusType(), + path.getStartName(), + Coordinate.from(path.getStartLongitude(), path.getStartLatitude()), + path.getEndName(), + Coordinate.from(path.getEndLongitude(), path.getEndLatitude()) + ); + } + + private static SubPath createWalking(ODsayRouteValidResponse.SubPath path) { + return new WalkPath(path.getDistanceMeter() * METER_TO_KILOMETER, path.getSectionTimeMin()); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/user/application/UserService.java b/src/main/java/com/example/mohago_nocar/user/application/UserService.java new file mode 100644 index 0000000..cd0e944 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/application/UserService.java @@ -0,0 +1,50 @@ +package com.example.mohago_nocar.user.application; + +import com.example.mohago_nocar.global.common.exception.CustomException; +import com.example.mohago_nocar.global.common.exception.GlobalStatus; +import com.example.mohago_nocar.user.domain.AnonymousUser; +import com.example.mohago_nocar.user.domain.UserRepository; +import com.example.mohago_nocar.user.domain.UserUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UserService implements UserUseCase { + + private final UserRepository userRepository; + + @Override + public AnonymousUser save(AnonymousUser user) { + return userRepository.save(user); + } + + @Override + public String getFcmToken(UUID userId) { + AnonymousUser user = findByIdOrThrow(userId); + return user.getFcmToken(); + } + + @Override + public AnonymousUser findByIdOrThrow(UUID userId) { + return findById(userId).orElseThrow(() -> new CustomException(GlobalStatus.ENTITY_NOT_FOUND)); + } + + @Override + public Optional findById(UUID userId) { + return userRepository.findById(userId); + } + + @Override + public AnonymousUser getOrCreate(String fcmToken) { + return userRepository.findByFcm(fcmToken) + .orElseGet(()-> { + AnonymousUser created = AnonymousUser.create(fcmToken); + return save(created); + }); + } + +} diff --git a/src/main/java/com/example/mohago_nocar/user/domain/AnonymousUser.java b/src/main/java/com/example/mohago_nocar/user/domain/AnonymousUser.java new file mode 100644 index 0000000..bb31674 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/domain/AnonymousUser.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.user.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.UUID; + + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class AnonymousUser { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + private String fcmToken; + + public static AnonymousUser create(String fcmToken) { + return AnonymousUser.builder() + .fcmToken(fcmToken).build(); + } + + @Builder(access = AccessLevel.PRIVATE) + private AnonymousUser(String uuid, String fcmToken) { + this.fcmToken = fcmToken; + } + +} + + diff --git a/src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java b/src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java new file mode 100644 index 0000000..f8426b0 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/domain/UserRepository.java @@ -0,0 +1,13 @@ +package com.example.mohago_nocar.user.domain; + +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository { + + AnonymousUser save(AnonymousUser identifier); + + Optional findById(UUID userId); + + Optional findByFcm(String fcmToken); +} diff --git a/src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java b/src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java new file mode 100644 index 0000000..628c99d --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/domain/UserUseCase.java @@ -0,0 +1,17 @@ +package com.example.mohago_nocar.user.domain; + +import java.util.Optional; +import java.util.UUID; + +public interface UserUseCase { + + AnonymousUser save(AnonymousUser user); + + String getFcmToken(UUID userId); + + AnonymousUser findByIdOrThrow(UUID userId); + + Optional findById(UUID userId); + + AnonymousUser getOrCreate(String fcmToken); +} diff --git a/src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java b/src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java new file mode 100644 index 0000000..48b3f41 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/infrastructure/AnonymousUserJpaRepository.java @@ -0,0 +1,11 @@ +package com.example.mohago_nocar.user.infrastructure; + +import com.example.mohago_nocar.user.domain.AnonymousUser; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface AnonymousUserJpaRepository extends JpaRepository { + Optional findByFcmToken(String fcmToken); +} diff --git a/src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java b/src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java new file mode 100644 index 0000000..1badca4 --- /dev/null +++ b/src/main/java/com/example/mohago_nocar/user/infrastructure/UserRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.example.mohago_nocar.user.infrastructure; + +import com.example.mohago_nocar.user.domain.AnonymousUser; +import com.example.mohago_nocar.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.UUID; + +@Repository +@Transactional +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final AnonymousUserJpaRepository anonymousUserJpaRepository; + + @Override + public AnonymousUser save(AnonymousUser user) { + return anonymousUserJpaRepository.save(user); + } + + @Override + public Optional findById(UUID userId) { + return anonymousUserJpaRepository.findById(userId); + } + + @Override + public Optional findByFcm(String fcmToken) { + return anonymousUserJpaRepository.findByFcmToken(fcmToken); + } + +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml deleted file mode 100644 index e139c52..0000000 --- a/src/main/resources/application-dev.yml +++ /dev/null @@ -1,38 +0,0 @@ -server: - port: ${SERVER_PORT:8080} - -spring: - config: - activate: - on-profile: "dev" - application: - name: mohago-nocar - datasource: - url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mohago_nocar} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} - driver-class-name: org.postgresql.Driver - jpa: - hibernate: - ddl-auto: update - default_batch_fetch_size: 1000 - jdbc: - time_zone: Asia/Seoul - defer-datasource-initialization: true - show-sql: true - open-in-view: false - -springdoc: - default-consumes-media-type: application/json - default-produces-media-type: application/json - swagger-ui: - operations-sorter: alpha - tags-sorter: alpha - -odsay: - url: https://api.odsay.com/v1/api/searchPubTransPathT - api-key: ${ODSAY_API_KEY} - -google: - url : https://places.googleapis.com/v1/ - api-key : ${GOOGLE_API_KEY} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..214c770 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,157 @@ +server: + port: ${SERVER_PORT:8080} + +sentry: + dsn: https://b7b02a49b4031327059e36430753f700@o4510496632930304.ingest.us.sentry.io/4510496642695168 + # Add data like request headers and IP for users, + # see https://docs.sentry.io/platforms/java/guides/spring-boot/data-management/data-collected/ for more info + send-default-pii: true + exception-resolver-order: -2147483647 + logging: + minimum-event-level: warn + minimum-breadcrumb-level: info + minimum-level: info + logs: + enabled: true + +spring: + threads: + virtual: + enabled: false + application: + name: mohago-nocar + profiles: + active: dev + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mohago_nocar} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + default_batch_fetch_size: 1000 + jdbc: + time_zone: Asia/Seoul + defer-datasource-initialization: true + show-sql: true + open-in-view: false + + data: + redis: + host: localhost + port: 6378 + +springdoc: + default-consumes-media-type: application/json + default-produces-media-type: application/json + swagger-ui: + operations-sorter: alpha + tags-sorter: alpha + +odsay: + request-url: https://api.odsay.com/v1/api/searchPubTransPathT + key: ${ODSAY_API_KEY} + execution-interval-mills: 230 + +redis: + streams: + odsay: + main: odsay-transit-api-requests + dlq: odsay-api-failures + plan: + main: plan-events + dlq: plan-events-failures + course: + optimized: + main: optimized-course + dlq: travel-spot-optimized-failures #삭제 + +google: + maps: + distance : https://maps.googleapis.com/maps/api/distancematrix/json + api-key : ${GOOGLE_API_KEY} + firebase: + key: + path: ${FIREBASE_KEY_PATH} + +kakao: + local: + category: https://dapi.kakao.com/v2/local/search/category + api-key: ${KAKAO_API_KEY} + +discord: + webhook-url: sdfdsfdsf + +logging: + level: + org.springframework.orm.jpa: DEBUG + org.springframework.transaction: DEBUG + org.hibernate.engine.transaction.internal.TransactionImpl: DEBUG + root: info +# org.springframework.jdbc.datasource.DataSourceUtils: DEBUG +# com.zaxxer.hikari: DEBUG +# org.springframework.data.redis.stream: TRACE +# org.springframework.data.redis: TRACE +# org.hibernate.SQL: debug +# org.hibernate.orm.jdbc.bind: trace +# com.fasterxml.jackson.databind: DEBUG +# org.springframework.http.converter.json: trace +# io.sentry: trace + +#logging: +# level: +# root: info +# com: +# google: +# firebase: debug +# +# io.lettuce.core.protocol: TRACE +# io.lettuce.core.RedisClient: TRACE +# org.springframework.data.redis: trace +# io: +# lettuce: trace + +# root: debug +# jdk.virtualThread: debug + +--- +spring: + config: + activate: + on-profile: dev +--- +spring: + config: + activate: + on-profile: test +# datasource: +# driver-class-name: org.h2.Driver +# url: jdbc:h2:mem:~/mohagoNoCar +# username: sa +# password: + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/mohagoNoCarTest + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: create +# jpa: +# database-platform: org.hibernate.dialect.H2Dialect +# hibernate: +# ddl-auto: update +# properties: +# hibernate: +# dialect: org.hibernate.dialect.H2Dialect +# show_sql: true +# format_sql: true +# h2: +# console: +# enabled: true +# path: /h2-console + data: + redis: + host: localhost + port: 6378 \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..b24212c --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + logs/app/error.log + + + ERROR + ACCEPT + DENY + + + + logs/app/%d{yyyy-MM-dd}-error.log + 30 + 5GB + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%level] [%thread] [%logger{36}] - %msg%n + + + + + + logs/app/warn.log + + + WARN + ACCEPT + DENY + + + + logs/app/%d{yyyy-MM-dd}-warn.log + 30 + 5GB + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%level] [%thread] [%logger{36}] - %msg%n + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/scripts/save_and_publish.lua b/src/main/resources/scripts/save_and_publish.lua new file mode 100644 index 0000000..9b58366 --- /dev/null +++ b/src/main/resources/scripts/save_and_publish.lua @@ -0,0 +1,16 @@ +-- KEYS[1] : 상태 저장용 Redis 키 (예: travelCourse:spotOptimized:entryStatus:{travelCourseId}) +-- KEYS[2] : 스트림 키 (예: travelCourse:spotOptimized:stream) +-- ARGV[1] : travelCourseId +-- ARGV[2] : status 값 (예: CREATED) +-- ARGV[3] : 이벤트 JSON 문자열 + +-- 1. 상태 저장 +redis.call('HSET', KEYS[1], 'travelCourseId', ARGV[1], 'status', ARGV[2]) + +-- 2. 스트림 발행 +local streamId = redis.call('XADD', KEYS[2], '*', + 'travelCourseId', ARGV[1], + 'event', ARGV[3] +) + +return streamId \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/CompletableFutureExceptionHandlingTest.java b/src/test/java/com/example/mohago_nocar/CompletableFutureExceptionHandlingTest.java new file mode 100644 index 0000000..b05475f --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/CompletableFutureExceptionHandlingTest.java @@ -0,0 +1,191 @@ +package com.example.mohago_nocar; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.*; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class CompletableFutureExceptionHandlingTest extends IntegrationTestSupport { + + private static ExecutorService executor; + + @BeforeAll + static void setup() { + // 테스트용 고정 스레드풀(동시성 제어를 위해) + executor = Executors.newFixedThreadPool(4); + } + + @AfterAll + static void tearDown() { + executor.shutdownNow(); + } + + @Test + @DisplayName("타임아웃의 적용 범위를 관찰한다") + void play_timeout(){ + //given + + //when + // 전체 타임 아웃 + CompletableFuture future = CompletableFuture.runAsync(() -> { + System.out.println("API call"); + try { + Thread.sleep(Duration.ofSeconds(5)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }, executor) + .orTimeout(3, TimeUnit.SECONDS) // 여기서 안걸림 + .exceptionally(throwable -> { + System.out.println("API call이 오래 걸림"); + return null; + }) + .thenRun(() -> { + System.out.println("Sending notify..."); + try { + Thread.sleep(Duration.ofSeconds(8)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }) + .orTimeout(10, TimeUnit.SECONDS); + + //then + future.join(); + } + + @Test + @DisplayName("체이닝된 CF 중 중간 CF가 예외를 던질 시 동작을 관찰한다") + void play_when_the_middle_future_throw_ex(){ + //given + CompletableFuture future = CompletableFuture.runAsync(() -> System.out.println("첫 번째 CF입니다. 1억 정도?! 받았어요.")) + .thenRun(() -> System.out.println("두 번째 CF입니다. 2억 정도?! 받았어요.")) + .thenRun(() -> { + System.out.println("BOOOMMM!!!"); + throw new RuntimeException("BBBBOOOOOOMMMMM~"); + }) + .thenRun(() -> { + System.out.println("BOOOMMM!!! BOOOMMM!!!"); + throw new RuntimeException("BBBBOOOOOOMMMMM~ BBBBOOOOOOMMMMM~"); + }) + .thenRun(() -> System.out.println("마지막 CF입니다. 100억 정도 받았어요.")) + .exceptionally(throwable -> { + System.out.println("CF 촬영 중 이슈가 발생했어요. 위약금 1000억입니다."); + return null; + }); + //when + try { + future.get(); + } catch (InterruptedException e) { + System.out.println("InterruptedException"); + throw new RuntimeException(e); + } catch (ExecutionException e) { + System.out.println("ExecutionException"); + throw new RuntimeException(e); + } + + //then + + } + + // 1) exceptionally()로 예외 복구 + @Test + void exceptionally_recoversFromException_and_chainContinues() { + CompletableFuture cf = CompletableFuture.supplyAsync(() -> { + throw new IllegalStateException("boom"); + }, executor) + // 예외 발생시 대체값 반환 + .exceptionally(ex -> { + // 확인용: ex는 CompletionException 또는 직접 던진 예외의 래핑일 수 있음 + return "default"; + }) + .thenApply(s -> s + "-continued"); + + assertThat(cf.join()).isEqualTo("default-continued"); + } + + // 2) handle()은 성공/실패 둘 다 처리 가능 + @Test + void handle_receivesResultOrException_and_canReturnDifferentValue() { + CompletableFuture cf = CompletableFuture.supplyAsync(() -> "ok", executor) + // 다음 단계에서 고의로 예외 발생 + .thenApply(s -> { + throw new RuntimeException("fail-in-thenApply"); + }) + // handle은 성공/실패 모두 실행됨 + .handle((res, ex) -> { + if (ex != null) { + // 예외가 발생한 경우 복구값 반환 + return "handled-recovery"; + } else { + return res; + } + }); + + assertThat(cf.join()).isEqualTo("handled-recovery"); + } + + // 3) whenComplete는 결과를 관찰만 하고 원래 결과를 넘겨준다 + @Test + void whenComplete_observesOutcome_but_doesNotChangeResult_unlessThrows() { + CompletableFuture cf = CompletableFuture.supplyAsync(() -> "good", executor) + .whenComplete((res, ex) -> { + // 관찰용 작업(로깅 등). 원래 결과를 변경하지 않음. + assertThat(ex).isNull(); + assertThat(res).isEqualTo("good"); + }); + + // whenComplete는 원본 결과를 그대로 전달 + assertThat(cf.join()).isEqualTo("good"); + + // 만약 whenComplete 안에서 예외를 던지면 그 예외가 전파된다. + CompletableFuture cf2 = CompletableFuture.supplyAsync(() -> "x", executor) + .whenComplete((res, ex) -> { + throw new IllegalArgumentException("observer-threw"); + }); + + assertThatThrownBy(cf2::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(IllegalArgumentException.class) + .hasRootCauseMessage("observer-threw"); + } + + // 4) 조합(thenCompose)된 단계에서 예외가 발생했을 때의 처리와 복구 + @Test + void thenCompose_exceptionInComposedStage_canBeRecoveredWithExceptionally() { + CompletableFuture initial = CompletableFuture.supplyAsync(() -> "start", executor); + + CompletableFuture pipeline = initial.thenCompose(s -> + // 내부에서 exception 발생 + CompletableFuture.supplyAsync(() -> { + throw new IllegalStateException("compose-boom"); + }, executor) + ) + // compose에서 발생한 예외를 여기서 복구 + .exceptionally(ex -> "composed-default") + .thenApply(s -> s + "-after"); + + assertThat(pipeline.join()).isEqualTo("composed-default-after"); + } + + // 5) join()/get() 시 예외 래핑(CompletionException/ExecutionException) 확인 + @Test + void join_wrapsCauseInCompletionException_whenStageFailed() { + CompletableFuture cf = CompletableFuture.supplyAsync(() -> { + throw new UnsupportedOperationException("underlying"); + }, executor); + + assertThatThrownBy(cf::join) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(UnsupportedOperationException.class) + .hasRootCauseMessage("underlying"); + } + +} diff --git a/src/test/java/com/example/mohago_nocar/OdsayThrottleTest.java b/src/test/java/com/example/mohago_nocar/OdsayThrottleTest.java new file mode 100644 index 0000000..a94bff7 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/OdsayThrottleTest.java @@ -0,0 +1,208 @@ +package com.example.mohago_nocar; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.support.IntegrationTestSupport; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiRateLimitedClient; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +public class OdsayThrottleTest extends IntegrationTestSupport { + +/* @Autowired + private ODsayApiRateLimitedClient odsayApiClient; + + private RateLimiter rateLimiter = initializeRateLimiter().rateLimiter("테스트 속도 조절기"); + + + @Test + @DisplayName("1초에 몇 번 형식인가?") + void test() throws InterruptedException { + //given + + //when + for (int i = 0; i < 20; i++) { + CompletableFuture.runAsync(() -> { + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute( + Coordinate.from(126.98708591399983, 37.56127528907461), + Coordinate.from(126.99023335682591, 37.55377929365595) + ); + + System.out.println(response); + }); + } + + Thread.sleep(Duration.ofSeconds(10)); + } + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.KOREAN); + + @Test + @DisplayName("속도 제한 로직이 문제인지 확인한다") + void testRateLimiter() { + //given + String currentThreadName = Thread.currentThread().getName(); + + //when + for (int i = 0; i < 15; i++) { + if (rateLimiter.acquirePermission()) { + System.out.println(formatter.format(LocalDateTime.now()) + ":" + currentThreadName); + } + } + + //then + + } + + ExecutorService executorService = Executors.newFixedThreadPool(10); + AtomicInteger index = new AtomicInteger(0); + + @Test + @DisplayName("토큰 버킷 알고리즘 동작을 확인한다.") + void playWithTokenBucket() throws InterruptedException { + //given + Bandwidth limit = BandwidthBuilder.builder().capacity(1) + .refillGreedy(2, Duration.ofSeconds(1)) + .build(); + + LocalBucket bucket = Bucket.builder() + .addLimit(limit) + .build(); + + Thread.sleep(100); + //when + for (int i = 0; i < 20; i++) { + int requestOrder = i; + CompletableFuture.runAsync(() -> { + + try { + bucket.asBlocking().consume(1); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + System.out.println( + "[획득] order=" + requestOrder + + ", thread=" + Thread.currentThread().getName() + + ", time=" + formatter.format(LocalDateTime.now()) + ); + + }); + } + Thread.sleep(Duration.ofSeconds(10)); + + } + + @Test + @DisplayName("blocking consume는 공평한 대기(FIFO)를 보장하지 않는다") + void playWithTokenBucket_unfairBlocking() throws InterruptedException { + // given + Bandwidth limit = BandwidthBuilder.builder() + .capacity(1) // 의도적으로 1로 설정 + .refillIntervally(1, Duration.ofSeconds(1)) + .initialTokens(1) + .build(); + + LocalBucket bucket = Bucket.builder() + .addLimit(limit) + .build(); + + int threadCount = 10; + + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + + for (int i = 0; i < threadCount; i++) { + int requestOrder = i; + + CompletableFuture.runAsync(() -> { + try { + // 모든 스레드가 준비될 때까지 대기 + readyLatch.countDown(); + startLatch.await(); + + System.out.println( + "[요청] order=" + requestOrder + + ", thread=" + Thread.currentThread().getName() + + ", time=" + formatter.format(LocalDateTime.now()) + ); + + bucket.asBlocking().consume(1); + + System.out.println( + "[획득] order=" + requestOrder + + ", thread=" + Thread.currentThread().getName() + + ", time=" + formatter.format(LocalDateTime.now()) + ); + + } catch (InterruptedException e) { + System.out.println("인터럽트 발생: " + requestOrder); + } + }, executorService); + } + + // 모든 스레드가 준비될 때까지 대기 + readyLatch.await(); + + System.out.println("=== 모든 요청 스레드 준비 완료, 동시에 시작 ==="); + startLatch.countDown(); + + Thread.sleep(Duration.ofSeconds(12)); + } + + + private RateLimiterRegistry initializeRateLimiter() { + RateLimiterConfig config = RateLimiterConfig.custom() + .limitRefreshPeriod(Duration.ofSeconds(1)) + .limitForPeriod(5) + .timeoutDuration(Duration.ofSeconds(30)) + .build(); + + return RateLimiterRegistry.of(config); + } + + private static Bucket bucket; + + @BeforeAll + public static void init() { + // 200ms마다 1개의 토큰을 엄격하게 추가 + Bandwidth limit = BandwidthBuilder.builder().capacity(1) + .refillGreedy(4, Duration.ofSeconds(1)) + .initialTokens(1) + .build(); + + bucket = Bucket.builder() + .addLimit(limit) + .build(); + } + + @Test + @DisplayName("넷플리그 속도 제한 로직 대신 구글을 사용해본다") + public void executeWithRateLimit() throws InterruptedException { + String currentThreadName = Thread.currentThread().getName(); + + Thread.sleep(Duration.ofSeconds(2)); + try { + for (int i = 0; i < 10; i++) { + bucket.asBlocking().consume(1); // 토큰을 얻을 때까지 블로킹 + System.out.println(formatter.format(LocalDateTime.now()) + ":" + currentThreadName); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }*/ + +} diff --git a/src/test/java/com/example/mohago_nocar/RetryTest.java b/src/test/java/com/example/mohago_nocar/RetryTest.java new file mode 100644 index 0000000..0936c06 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/RetryTest.java @@ -0,0 +1,111 @@ +package com.example.mohago_nocar; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.*; +import java.util.function.Supplier; + +public class RetryTest { + +/* ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + Logger log = LoggerFactory.getLogger(RetryTest.class); + + public Retry init() { + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(3) + .retryOnException(e -> !(e instanceof RuntimeException)) + .intervalFunction(IntervalFunction.ofExponentialBackoff(1000, 2.0)) // 1초, 2배씩 증가 + .build(); + + Retry retry = Retry.of("transitApiRetry", retryConfig); + retry.getEventPublisher() + .onRetry(event -> log.warn("재시도 발생: {}번째 시도, 원인={}", + event.getNumberOfRetryAttempts(), + event.getLastThrowable().toString())) + .onError(throwable -> log.error("재시도 실패: 원인={}", + throwable.getLastThrowable().toString())); + + return retry; + } + + @Test + @DisplayName("DecorateCallable을 사용해봐요") + void testDecorateCallable(){ + //given + Retry retry = init(); + + //when + Callable callable = Retry.decorateCallable(retry, + () -> { + blockAndPrintAndThrowCheckedException("API 응답 대기 중..."); + return null; + }); + + //then + try { + callable.call(); + } catch (Exception e) { + System.out.println("API 호출에 실패했어요 ㅜㅜ"); + throw new RuntimeException(e); + } + + } + + private void blockAndPrintAndThrowCheckedException(String message) throws IOException { + try { + System.out.println(Thread.currentThread().getName() + ": " + "키 획득 대기를 시작합니다"); + Thread.sleep(Duration.ofSeconds(1)); // 여기까지 호출자 스레드가 수행(처음에만), equal acquire key + System.out.println(Thread.currentThread().getName() + ":" + message); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + throw new IOException("아이쿠! 실패!"); + } + + @Test + @DisplayName("CompletableFuture를 재시도 해요") + void testRetry() { + //given + Retry retry = init(); + + //when + Supplier> stageSupplier = Retry.decorateCompletionStage( + retry, + scheduler, + () -> blockAndPrint("API 응답 대기 중...") + ); + + CompletionStage stage = stageSupplier.get(); + CompletableFuture future = stage.thenRun(() -> System.out.println("API 호출 완료")) + .toCompletableFuture(); + try { + future.join(); + } catch (Exception e) { + System.out.println("API 호출에 실패했어요 ㅜㅜ"); + } + + //then + + } + + CompletableFuture blockAndPrint(String printMsg) { + try { + System.out.println(Thread.currentThread().getName() + ": " + "키 획득 대기를 시작합니다"); + Thread.sleep(Duration.ofSeconds(3)); // 여기까지 호출자 스레드가 수행(처음에만), equal acquire key + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return CompletableFuture.runAsync( + () -> { + System.out.println(Thread.currentThread().getName() + ":" + printMsg); + throw new RuntimeException("API 호출 실패"); + }); // equal api call + }*/ + +} diff --git a/src/test/java/com/example/mohago_nocar/RouteStepTest.java b/src/test/java/com/example/mohago_nocar/RouteStepTest.java new file mode 100644 index 0000000..fd912d3 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/RouteStepTest.java @@ -0,0 +1,160 @@ +package com.example.mohago_nocar; + +import com.example.mohago_nocar.course.application.route.RouteStepService; +import com.example.mohago_nocar.course.domain.model.course.TravelCourse; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.service.TravelCourseUseCase; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.support.IntegrationTestSupport; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import com.example.mohago_nocar.transit.domain.model.*; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class RouteStepTest extends LocalIntegrationTestSupport { + + @Autowired + private TravelCourseUseCase travelCourseUseCase; + + @Autowired + private RouteStepService routeStepService; + + @Test + @DisplayName("RouteStep을 저장하고 조회할 때 SubPath 리스트가 JSONB로 정상 직렬화/역직렬화된다") + void saveAndRetrieveRouteStepWithSubPaths() { + // given: SubPath 리스트를 포함한 RouteStep 생성 + WalkPath walkPath = new WalkPath(1.5, 15); + + Coordinate busStartCoord = Coordinate.from(37.5547, 126.9707); + Coordinate busEndCoord = Coordinate.from(37.5600, 126.9800); + BusPath busPath = new BusPath( + 5.2, 20, "402", 1, + "서울역", busStartCoord, + "시청역", busEndCoord + ); + + Coordinate subwayStartCoord = Coordinate.from(37.5600, 126.9800); + Coordinate subwayEndCoord = Coordinate.from(37.4979, 127.0276); + SubwayPath subwayPath = new SubwayPath( + 10.3, 25, "2호선", + "시청역", subwayStartCoord, + "강남역", subwayEndCoord + ); + + List subPaths = List.of(walkPath, busPath, subwayPath); + System.out.println("저장 전 ============="); + subPaths.forEach(System.out::println); + System.out.println("===================="); + + RouteStep routeStep = RouteStep.builder() + .originSpotId(1L) + .destinationSpotId(2L) + .timeTakenMin(60) + .distanceKm(17.1) + .detailPaths(subPaths) + .build(); + + // finalizeRoute로 확인 + // when: 저장 + List routeSteps = routeStepService.saveAll(List.of(routeStep)); + System.out.println("routeSteps returned saveAll method " + routeSteps); + + List ids = routeSteps.stream().map(route -> route.getId()).toList(); + List found = routeStepService.findAll(ids); + System.out.println("routeSteps returned findAll method " + routeSteps); + + // then: 조회 및 검증 +// RouteStep found = entityManager.find(RouteStep.class, routeStep.getId()); +// +// assertThat(found).isNotNull(); +// assertThat(found.getOriginSpotId()).isEqualTo(1L); +// assertThat(found.getDestinationSpotId()).isEqualTo(2L); +// assertThat(found.getTimeTakenMin()).isEqualTo(60); +// assertThat(found.getDistanceKm()).isEqualTo(17.1); +// +// // SubPath 리스트 검증 +// List foundPaths = found.getDetailPaths(); +// assertThat(foundPaths).hasSize(3); +// System.out.println("조회 완료 ================="); +// foundPaths.forEach(System.out::println); +// System.out.println("========================="); +// +// // 첫 번째 경로 - WalkSubPath +// SubPath firstPath = foundPaths.get(0); +// System.out.println(firstPath); +// assertThat(firstPath).isInstanceOf(WalkPath.class); +// assertThat(firstPath.getDistanceKm()).isEqualTo(1.5); +// assertThat(firstPath.getTimeTakenMin()).isEqualTo(15); +// assertThat(firstPath.getPathType()).isEqualTo(PathType.WALK); +// +// // 두 번째 경로 - BusPath +// SubPath secondPath = foundPaths.get(1); +// assertThat(secondPath).isInstanceOf(BusPath.class); +// assertThat(secondPath.getDistanceKm()).isEqualTo(5.2); +// assertThat(secondPath.getTimeTakenMin()).isEqualTo(20); +// BusPath busPathResult = (BusPath) secondPath; +// assertThat(busPathResult.getBusNo()).isEqualTo("402"); +// assertThat(busPathResult.getBusType()).isEqualTo(1); +// assertThat(busPathResult.getStartName()).isEqualTo("서울역"); +// assertThat(busPathResult.getEndName()).isEqualTo("시청역"); +// assertThat(secondPath.getPathType()).isEqualTo(PathType.BUS); +// +// // 세 번째 경로 - SubwayPath +// SubPath thirdPath = foundPaths.get(2); +// assertThat(thirdPath).isInstanceOf(SubwayPath.class); +// assertThat(thirdPath.getDistanceKm()).isEqualTo(10.3); +// assertThat(thirdPath.getTimeTakenMin()).isEqualTo(25); +// SubwayPath subwayPathResult = (SubwayPath) thirdPath; +// assertThat(subwayPathResult.getSubwayLineName()).isEqualTo("2호선"); +// assertThat(subwayPathResult.getStartName()).isEqualTo("시청역"); +// assertThat(subwayPathResult.getEndName()).isEqualTo("강남역"); +// assertThat(thirdPath.getPathType()).isEqualTo(PathType.SUBWAY); + } + + @Test + @DisplayName("추상 클래스를 jsonb type으로 저장할 수 있다") + void shouldSaved(){ + // given: SubPath 리스트를 포함한 RouteStep 생성 + WalkPath walkPath = new WalkPath(1.5, 15); + + Coordinate busStartCoord = Coordinate.from(37.5547, 126.9707); + Coordinate busEndCoord = Coordinate.from(37.5600, 126.9800); + BusPath busPath = new BusPath( + 5.2, 20, "402", 1, + "서울역", busStartCoord, + "시청역", busEndCoord + ); + + Coordinate subwayStartCoord = Coordinate.from(37.5600, 126.9800); + Coordinate subwayEndCoord = Coordinate.from(37.4979, 127.0276); + SubwayPath subwayPath = new SubwayPath( + 10.3, 25, "2호선", + "시청역", subwayStartCoord, + "강남역", subwayEndCoord + ); + + List subPaths = List.of(walkPath, busPath, subwayPath); + + RouteStep routeStep = RouteStep.builder() + .originSpotId(1L) + .destinationSpotId(2L) + .timeTakenMin(60) + .distanceKm(17.1) + .detailPaths(subPaths) + .build(); + + // when: 저장 +// entityManager.persist(routeStep); + } + + // 1. 엔티티 관련 문제 + // 2. pojo 직렬화/역직렬화 문제 -> subpath pojo 역직렬화/직렬화해보기 + +} diff --git a/src/test/java/com/example/mohago_nocar/SentrySendTest.java b/src/test/java/com/example/mohago_nocar/SentrySendTest.java new file mode 100644 index 0000000..e7647e6 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/SentrySendTest.java @@ -0,0 +1,26 @@ +package com.example.mohago_nocar; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import io.sentry.Sentry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class SentrySendTest extends IntegrationTestSupport { + + @Test + @DisplayName("") + void shouldSendAlert(){ + //given + + //when + try { + throw new Exception("This is a test."); + } catch (Exception e) { + Sentry.captureException(e); + } + + //then + + } + +} diff --git a/src/test/java/com/example/mohago_nocar/TravelSpotOrderAssignmentPerformanceTest.java b/src/test/java/com/example/mohago_nocar/TravelSpotOrderAssignmentPerformanceTest.java new file mode 100644 index 0000000..7dd336a --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/TravelSpotOrderAssignmentPerformanceTest.java @@ -0,0 +1,242 @@ +package com.example.mohago_nocar; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.plan.domain.model.Location; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.stream.Collectors; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +public class TravelSpotOrderAssignmentPerformanceTest { + @Test + @DisplayName("방식1(Map 인덱싱)과 방식2(중첩 루프) 성능 비교 - 5개 장소") + void comparePerformanceWith5Spots() { + // Given: 5개의 TravelSpot (일부 중복 좌표 포함) + Set spots1 = createTestSpots(100); + Set spots2 = new HashSet<>(spots1); + + List optimalRoute = spots1.stream() + .map(s -> s.getLocation().getCoordinate()) + .collect(Collectors.toList()); + + // When & Then: 방식1 (Map 인덱싱) + long startTime1 = System.nanoTime(); + assignOrderWithMap(spots1, optimalRoute); + long endTime1 = System.nanoTime(); + long duration1 = endTime1 - startTime1; + + // When & Then: 방식2 (중첩 루프) + long startTime2 = System.nanoTime(); + assignOrderWithNestedLoop(spots2, optimalRoute); + long endTime2 = System.nanoTime(); + long duration2 = endTime2 - startTime2; + + // 결과 검증 + assertThat(spots1).allMatch(s -> s.getVisitOrder() != null); + assertThat(spots2).allMatch(s -> s.getVisitOrder() != null); + + // 성능 비교 출력 + System.out.println("=== 100개 장소 성능 비교 ==="); + System.out.println("방식1 (Map 인덱싱): " + duration1 + " ns (" + duration1/1000.0 + " μs)"); + System.out.println("방식2 (중첩 루프): " + duration2 + " ns (" + duration2/1000.0 + " μs)"); + System.out.println("속도 차이: " + (duration2 > duration1 ? + "방식1이 " + (duration2 - duration1) + " ns 더 빠름" : + "방식2가 " + (duration1 - duration2) + " ns 더 빠름")); + } + + @RepeatedTest(10) + @DisplayName("반복 테스트로 평균 성능 측정 - 5개 장소") + void repeatedPerformanceTest() { + Set spots1 = createTestSpots(5); + Set spots2 = new HashSet<>(spots1); + + List optimalRoute = spots1.stream() + .map(s -> s.getLocation().getCoordinate()) + .collect(Collectors.toList()); + + long startTime1 = System.nanoTime(); + assignOrderWithMap(spots1, optimalRoute); + long duration1 = System.nanoTime() - startTime1; + + long startTime2 = System.nanoTime(); + assignOrderWithNestedLoop(spots2, optimalRoute); + long duration2 = System.nanoTime() - startTime2; + + System.out.printf("방식1: %d ns | 방식2: %d ns | 차이: %d ns%n", + duration1, duration2, Math.abs(duration1 - duration2)); + } + + @Test + @DisplayName("중복 좌표가 있는 경우 테스트") + void testWithDuplicateCoordinates() { + // Given: 중복 좌표 포함 + Coordinate dupCoord = Coordinate.from(127.0, 37.5); + Set spots = new HashSet<>(); + spots.add(new TestTravelSpot(1L, null, new Location(dupCoord, "장소1"))); + spots.add(new TestTravelSpot(1L, null, new Location(dupCoord, "장소2"))); + spots.add(new TestTravelSpot(1L, null, new Location(Coordinate.from(127.1, 37.6), "장소3"))); + +// List route = Arrays.asList( +// Coordinate.from(127.1, 37.6), +// dupCoord +// ); + + List route = Arrays.asList( + dupCoord + ); + + // When + Set spots1 = new HashSet<>(spots); + Set spots2 = new HashSet<>(spots); + + assignOrderWithMap(spots1, route); + + // Then: 중복 좌표의 경우 Map 방식은 마지막 것만, 중첩 루프는 모두 처리 + System.out.println("\n=== 중복 좌표 처리 결과 ==="); + System.out.println("방식1 (Map): " ); + spots1.stream() + .forEach(s -> System.out.println(s.getLocation().name + ": "+ s.getVisitOrder())); + + assignOrderWithNestedLoop(spots2, route); + System.out.println("방식2 (중첩 루프): " ); + spots2.stream() + .forEach(s -> System.out.println(s.getLocation().name + ": "+ s.getVisitOrder())); + } + + @Test + @DisplayName("정확성 검증 - 순서가 올바르게 할당되는지 확인") + void verifyCorrectness() { + // Given + Set spots = createTestSpots(5); + List route = spots.stream() + .map(s -> s.getLocation().getCoordinate()) + .collect(Collectors.toList()); + + Set spots1 = new HashSet<>(spots); + Set spots2 = new HashSet<>(spots); + + // When + assignOrderWithMap(spots1, route); + assignOrderWithNestedLoop(spots2, route); + + // Then + List orders1 = spots1.stream() + .map(TestTravelSpot::getVisitOrder) + .sorted() + .collect(Collectors.toList()); + + List orders2 = spots2.stream() + .map(TestTravelSpot::getVisitOrder) + .sorted() + .collect(Collectors.toList()); + + assertThat(orders1).containsExactly(0, 1, 2, 3, 4); + assertThat(orders2).containsExactly(0, 1, 2, 3, 4); + } + + // 방식1: Map 인덱싱 + private void assignOrderWithMap(Set unOrderedTravelSpots, List optimalRoute) { + Map> index = unOrderedTravelSpots.stream() + .collect(Collectors.groupingBy(s -> s.getLocation().getCoordinate())); + + int order = 0; + for (Coordinate next : optimalRoute) { + List spots = index.get(next); + if (spots != null) { + for (TestTravelSpot spot : spots) { + spot.setOrder(order++); + } + } + } + } + + + // 방식2: 중첩 루프 + private void assignOrderWithNestedLoop(Set unOrderedTravelSpots, List optimalRoute) { + int order = 0; + for (Coordinate nxt : optimalRoute) { + for (TestTravelSpot travelSpot : unOrderedTravelSpots) { + Coordinate spotCoordi = travelSpot.getLocation().getCoordinate(); + if (spotCoordi.equals(nxt)) { + travelSpot.setOrder(order++); + } + } + } + if (order != optimalRoute.size()) { + throw new RuntimeException("모든 장소에 순서가 부여되지 못했거나 여행 장소의 경위도 외의 추가 경위도가 존재합니다."); + } + } + + // 테스트용 데이터 생성 + private Set createTestSpots(int count) { + Set spots = new HashSet<>(); + for (int i = 0; i < count; i++) { + Coordinate coord = Coordinate.from(127.0 + i * 0.1, 37.5 + i * 0.1); + spots.add(new TestTravelSpot(1L, null, new Location(coord, "장소" + i))); + } + return spots; + } + + // 테스트용 TravelSpot 구현 + static class TestTravelSpot { + private Long id; + private Long courseId; + private Integer visitOrder; + private Location location; + + public TestTravelSpot(Long courseId, Integer visitOrder, Location location) { + this.courseId = courseId; + this.visitOrder = visitOrder; + this.location = location; + } + + public void setOrder(int i) { + visitOrder = i; + } + + public Integer getVisitOrder() { + return visitOrder; + } + + public Location getLocation() { + return location; + } + + @Override + public String toString() { + return "TestTravelSpot{" + + "id=" + id + + ", courseId=" + courseId + + ", visitOrder=" + visitOrder + + ", location=" + location + + '}'; + } + } + + static class Location { + private Coordinate coordinate; + private String name; + + public Location(Coordinate coordinate, String name) { + this.coordinate = coordinate; + this.name = name; + } + + public Coordinate getCoordinate() { + return coordinate; + } + + @Override + public String toString() { + return "Location{" + + "coordinate=" + coordinate + + ", name='" + name + '\'' + + '}'; + } + } + +} diff --git a/src/test/java/com/example/mohago_nocar/course/application/route/RouteStepFinderTest.java b/src/test/java/com/example/mohago_nocar/course/application/route/RouteStepFinderTest.java new file mode 100644 index 0000000..8b2bb12 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/course/application/route/RouteStepFinderTest.java @@ -0,0 +1,111 @@ +package com.example.mohago_nocar.course.application.route; + +import com.example.mohago_nocar.course.application.spot.TravelSpotService; +import com.example.mohago_nocar.course.domain.model.routeStep.RouteStep; +import com.example.mohago_nocar.course.domain.model.travelSpot.TravelSpot; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.*; + +import static org.assertj.core.api.Assertions.assertThat; + +class RouteStepFinderTest extends LocalIntegrationTestSupport { + + @Autowired + private RouteFinder routeStepFinder; + + @Autowired + private TravelSpotService travelSpotService; + +/* @Test + @DisplayName("호출 순서대로 API 요청을 완료된다.") + void shouldCompleteOneByOne() throws InterruptedException { + //given + long travelCourseId = 52L; + List spots = travelSpotService.getByCourseId(travelCourseId); + + CountDownLatch countDownLatch = new CountDownLatch(1); + ExecutorService executorService = Executors.newFixedThreadPool(10); + + for (int i = 0; i < 10; i++) { + int finalI = i; + countDownLatch.await(); + executorService.submit(() -> { + List> futures = routeStepFinder.findRouteInTravelCourse(travelCourseId, spots); + List routeSteps = futures.stream().map(CompletableFuture::join).toList(); + System.out.println(finalI +"번째 요청 완료: " + routeSteps); + }); + } + + countDownLatch.countDown(); + + //when + + //then + + }*/ + + @Test + @DisplayName("호출 순서대로 API 요청을 완료한다.") + void shouldCompleteOneByOne() throws InterruptedException { + // given + long travelCourseId = 25L; + List spots = travelSpotService.getByCourseId(travelCourseId); + System.out.println("spots size: " + spots.size()); + + int requestCount = 1; + System.out.println("total request count: " + requestCount * (spots.size() - 1)); + + CountDownLatch latch = new CountDownLatch(requestCount); + ExecutorService executorService = Executors.newFixedThreadPool(requestCount); + + // 예외 스택트레이스를 모아둘 리스트 + List exceptions = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < requestCount; i++) { + executorService.submit(() -> { + try { + List> futures = + routeStepFinder.findRouteInTravelCourse(travelCourseId, new ArrayList<>(spots)); + List routeSteps = new ArrayList<>(); + for (CompletableFuture future : futures) { + RouteStep join = future.join(); + routeSteps.add(join); + } + System.out.println("응답: " + routeSteps); + System.out.println(Thread.currentThread() + "의 임무 완료 시각 :" + LocalDateTime.now()); + + } catch (Exception e) { + // 예외를 리스트에 저장 + exceptions.add(e); + } finally { + latch.countDown(); + } + }); + } + + boolean completed = latch.await(120, TimeUnit.SECONDS); + Thread.sleep(Duration.ofSeconds(50)); + executorService.shutdown(); + + // 모든 예외 출력 + if (!exceptions.isEmpty()) { + System.out.println("=== 발생한 예외 스택트레이스 ==="); + for (Throwable t : exceptions) { + t.printStackTrace(System.out); + } + } + + assertThat(completed).isTrue(); + } + + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/course/application/spot/TravelSpotServiceTest.java b/src/test/java/com/example/mohago_nocar/course/application/spot/TravelSpotServiceTest.java new file mode 100644 index 0000000..d74fc0f --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/course/application/spot/TravelSpotServiceTest.java @@ -0,0 +1,21 @@ +package com.example.mohago_nocar.course.application.spot; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class TravelSpotServiceTest { + + @Test + @DisplayName("") + void shouldDetermineTravelOrder(){ + //given + + //when + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisherTest.java b/src/test/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisherTest.java new file mode 100644 index 0000000..e492165 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/course/infrastructure/course/messaging/TravelCourseOptimizedEventPublisherTest.java @@ -0,0 +1,31 @@ +package com.example.mohago_nocar.course.infrastructure.course.messaging; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class TravelCourseOptimizedEventPublisherTest extends LocalIntegrationTestSupport { + + @Autowired + private TravelCourseOptimizedEventPublisher publisher; + + @Test + @DisplayName("") + void shouldPublish() throws InterruptedException { + //given //when + for (int i = 0; i < 5; i++) { + publisher.publish(TravelCourseOptimizedEvent.of(61L, UUID.fromString("96ed22d6-ffd9-41c5-931a-d4a662e5054a"))); + } + + //then + Thread.sleep(Duration.ofSeconds(10)); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessorTest.java b/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessorTest.java new file mode 100644 index 0000000..c65bfcc --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/DeadMessageProcessorTest.java @@ -0,0 +1,68 @@ +package com.example.mohago_nocar.course.infrastructure.stream; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.messaging.DeadLetterQueueEntryRepository; +import com.example.mohago_nocar.global.util.RedisStreamHelper; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.stream.*; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.BDDMockito.*; + +class DeadMessageProcessorTest extends LocalIntegrationTestSupport { + + @Autowired + DeadLetterQueueEntryRepository deadLetterQueueEntryRepository; + + @MockBean + RedisStreamHelper redisStreamHelper; + + @Test + @DisplayName("예외 발생 시 트랜잭션에 속한 작업들은 모두 롤백된다.") + void shouldRollbackThrownException(){ + // given: ack 단계에서 예외 발생하도록 설정 + willThrow(new RuntimeException("Redis Acknowledge 실패")) + .given(redisStreamHelper) + .acknowledgeAndDelete(anyString(), anyString(), any(RecordId[].class)); + + // given: readMessage는 정상 반환 + RecordId recordId = RecordId.of("1670000000-0"); + PendingMessage pendingMessage = new PendingMessage( + recordId, + Consumer.from("consumerGroup", "consumer"), // Consumer 객체 + Duration.ofMillis(1000), // 마지막 전달 이후 경과 시간 + 1L // 총 전달 횟수 + ); + + MapRecord mapRecord = + StreamRecords.mapBacked(Map.of( + (Object) "제발 끝내줘", (Object) TravelCourseOptimizedEvent.of(1L, UUID.randomUUID()))) + .withStreamKey("myStream") + .withId(recordId); + + given(redisStreamHelper.readMessage(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .willReturn(mapRecord); + +/* // when: DeadMessageProcessor 실행 → 예외 발생 + assertThatThrownBy(() -> + deadMessageProcessor.processLongPendingMessages(List.of(pendingMessage)) + ).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Redis Acknowledge 실패"); + + // then: DeadLetterQueueEntryRepository에는 데이터가 남아있지 않아야 함 + assertThat(deadLetterQueueEntryRepository.findByEntryId(recordId.getValue())).isEmpty();*/ + } + + // todo: making pending messages. half already has stored as DeadLetterQueueEntryDto to test + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/TravelCourseOptimizedStreamRecoveryManagerTest.java b/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/TravelCourseOptimizedStreamRecoveryManagerTest.java new file mode 100644 index 0000000..bc2face --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/course/infrastructure/stream/TravelCourseOptimizedStreamRecoveryManagerTest.java @@ -0,0 +1,36 @@ +package com.example.mohago_nocar.course.infrastructure.stream; + +import com.example.mohago_nocar.course.infrastructure.course.messaging.TravelCourseOptimizedStreamRecoveryManager; +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +class TravelCourseOptimizedStreamRecoveryManagerTest extends IntegrationTestSupport { + + @Value("${redis.streams.travel-spot.main}") + private String streamKey; + + private static final String CONSUMER_GROUP = "processors"; + private static final String CONSUMER_1 = "processor-1"; + + @Autowired + private TravelCourseOptimizedStreamRecoveryManager recoveryManager; + + @Test + @DisplayName("동작 테스트") + void test() { + //given + // pel에 엔트리 생성 + // recovery + // delivery count, idle time check + + //when + recoveryManager.recovery(streamKey, CONSUMER_GROUP, CONSUMER_1); + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryTest.java b/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryTest.java new file mode 100644 index 0000000..c97a03a --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueEntryTest.java @@ -0,0 +1,26 @@ +package com.example.mohago_nocar.global.messaging; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.PendingMessage; +import org.springframework.data.redis.connection.stream.RecordId; + +import java.time.Duration; + +class DeadLetterQueueEntryTest { + + @Test + void create() { + PendingMessage pendingMessage = new PendingMessage( + RecordId.of("1670000000-0"), + Consumer.from("consumerGroup", "consumer"), // Consumer 객체 + Duration.ofMillis(1000), // 마지막 전달 이후 경과 시간 + 1L // 총 전달 횟수 + ); + + DeadLetterQueueEntry entry = DeadLetterQueueEntry.create( + DeadLetterQueueEntryDto.from("Sdfdsf", pendingMessage, "ㄹㄴㅇㄹㅇ")); + System.out.println(entry); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueServiceTest.java b/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueServiceTest.java new file mode 100644 index 0000000..d0cd7a8 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/global/messaging/DeadLetterQueueServiceTest.java @@ -0,0 +1,27 @@ +package com.example.mohago_nocar.global.messaging; + +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class DeadLetterQueueServiceTest extends LocalIntegrationTestSupport { + + @Autowired + DeadLetterQueueService deadLetterQueueService; + + @Test + @DisplayName("Dead Letter를 저장한다.") + void shouldSaveDeadLetter(){ + //given + DeadLetterQueueEntryDto dto = DeadLetterQueueEntryDto.of("id", "streamName", "groupName", "consumerName", + "payload", new Throwable("테스트 중!")); + + //when + deadLetterQueueService.save(dto); + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/global/messaging/RedisStreamHelperTest.java b/src/test/java/com/example/mohago_nocar/global/messaging/RedisStreamHelperTest.java new file mode 100644 index 0000000..2833213 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/global/messaging/RedisStreamHelperTest.java @@ -0,0 +1,68 @@ +package com.example.mohago_nocar.global.messaging; + +import com.example.mohago_nocar.course.domain.event.TravelCourseOptimizedEvent; +import com.example.mohago_nocar.global.util.ObjectMapperUtil; +import com.example.mohago_nocar.global.util.RedisStreamHelper; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Map; +import java.util.UUID; + +class RedisStreamHelperTest extends LocalIntegrationTestSupport { + + @Autowired + private ObjectMapperUtil objectMapper; + + @Autowired + private RedisStreamHelper redisStreamHelper; + + @Autowired + private StringRedisTemplate redisTemplate; + + @Test + @DisplayName("") + void shouldAddStream(){ + //given + String eventJson = objectMapper.writeValue( + TravelCourseOptimizedEvent.of(1L, UUID.randomUUID()) + ); + + //when + ObjectRecord record = StreamRecords.newRecord().in("mystream").ofObject(eventJson); + RecordId recordId = redisTemplate.opsForStream().add(record); + + //then + System.out.println("recordId: " + recordId); + MapRecord entries = redisStreamHelper.readMessage("mystream", RecordId.of(recordId.getValue())); + System.out.println(entries.toString()); + System.out.println(entries.getValue()); + } + + @Test + @DisplayName("레코드(엔트리)를 읽는다") + void shouldReadRecord(){ + //given + //when + MapRecord record = redisStreamHelper.readMessage("mystream", RecordId.of("1766322817723-0")); + RecordId id = record.getId(); + Map recordValue = record.getValue(); + ObjectRecord objRecord = ObjectRecord.create("mystream", recordValue.toString()).withId(id); + System.out.println(objRecord); + String stream = objRecord.getStream(); + System.out.println(stream); + + //then + System.out.println(record.toString()); + System.out.println(record.getValue()); + // 결과: MapBackedRecord{recordId=1761874798218-0, kvMap={user_name=k}} + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/rateLimiter/RateLimiterTest.java b/src/test/java/com/example/mohago_nocar/rateLimiter/RateLimiterTest.java new file mode 100644 index 0000000..c009686 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/rateLimiter/RateLimiterTest.java @@ -0,0 +1,49 @@ +package com.example.mohago_nocar.rateLimiter; + +import com.example.mohago_nocar.global.rateLimit.IntervalRateLimiter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +class RateLimiterTest { + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.KOREAN); + + @Test + @DisplayName("rate limit test") + void shouldRateLimit() throws InterruptedException { + //given + IntervalRateLimiter rateLimiter = new IntervalRateLimiter(200); + + //when + for (int i = 0; i < 5; i++) { + final int order = i; + CompletableFuture.runAsync(() -> { + rateLimiter.throttle(); + System.out.println(order + "요청 받았습니다! : " + LocalDateTime.now().format(formatter)); + }); + + } + + Thread.sleep(Duration.ofMillis(300)); + + for (int i = 6; i < 10; i++) { + final int order = i; + CompletableFuture.runAsync(() -> { + rateLimiter.throttle(); + System.out.println(order + "요청 받았습니다! : " + LocalDateTime.now().format(formatter)); + }); + + } + + //then + Thread.sleep(Duration.ofSeconds(3)); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/redis/TemplateStudy.java b/src/test/java/com/example/mohago_nocar/redis/TemplateStudy.java new file mode 100644 index 0000000..d3246cd --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/redis/TemplateStudy.java @@ -0,0 +1,46 @@ +package com.example.mohago_nocar.redis; + +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.support.Fixtures; +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +public class TemplateStudy extends IntegrationTestSupport { + + @Autowired + private RedisTemplate stringRedisTemplate; + + @Autowired + private RedisTemplate objectRedisTemplate; + + @Test + @DisplayName("이 뭐야") + void whatIsStringString(){ + //given + + //when + stringRedisTemplate.opsForValue().set("hello", "world"); + stringRedisTemplate.opsForList().leftPush("hello2", "world"); + stringRedisTemplate.opsForList().leftPush("hello2", "hello"); + + //then + System.out.println(stringRedisTemplate.opsForValue().get("hello")); + System.out.println(stringRedisTemplate.opsForList().range("hello2", 0, 2)); + } + + @Test + @DisplayName("가 뭐야") + void whatIsStringObject(){ + //given + //when + Location location = Fixtures.location(); + objectRedisTemplate.opsForValue().set("hello", location); + + //then + System.out.println(objectRedisTemplate.opsForValue().get("hello")); + } + +} diff --git a/src/test/java/com/example/mohago_nocar/support/FakeDataFactoryConfig.java b/src/test/java/com/example/mohago_nocar/support/FakeDataFactoryConfig.java new file mode 100644 index 0000000..72b0496 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/support/FakeDataFactoryConfig.java @@ -0,0 +1,18 @@ +package com.example.mohago_nocar.support; + +import net.datafaker.Faker; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Locale; + +@TestConfiguration +public class FakeDataFactoryConfig { + + @Bean + public Faker faker() { + return new Faker(new Locale("ko")); + } + +} diff --git a/src/test/java/com/example/mohago_nocar/support/Fixtures.java b/src/test/java/com/example/mohago_nocar/support/Fixtures.java new file mode 100644 index 0000000..90c929c --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/support/Fixtures.java @@ -0,0 +1,171 @@ +package com.example.mohago_nocar.support; + +import com.example.mohago_nocar.festival.domain.model.Festival; +import com.example.mohago_nocar.festival.domain.model.vo.ActivePeriod; +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.place.domain.model.Place; +import com.example.mohago_nocar.place.domain.model.PlaceCategory; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import net.datafaker.Faker; + +import java.time.LocalDate; +import java.util.List; +import java.util.Locale; +import java.util.stream.IntStream; + +public class Fixtures { + + static Faker faker = new Faker(Locale.KOREA); + + public static List places(int numberOfPlaces) { + return IntStream.range(0, numberOfPlaces).mapToObj(i -> place()).toList(); + } + + private static Place place() { + return Place.from( + faker.number().digits(4), // 랜덤 ID + faker.company().name() + " " + faker.options().option("해수욕장", "공원", "시장", "온천", "미술관"), // 장소 이름 + Coordinate.from( + faker.number().randomDouble(8, 126, 130), // 한국 경도 범위 + faker.number().randomDouble(8, 34, 38) // 한국 위도 범위 + ), + faker.address().fullAddress(), + faker.internet().url(), + faker.options().option(PlaceCategory.values()) + ); + } + + public static Festival festival() { + LocalDate startDate = LocalDate.now().minusDays(faker.number().numberBetween(1, 14)); + LocalDate endDate = LocalDate.now().plusDays(faker.number().numberBetween(1, 7)); + + return Festival.from( + faker.expression(faker.name() + "축제"), // 자연스러운 축제 이름 + ActivePeriod.from(startDate, endDate), + faker.lorem().sentence(8) + " 🎉", // 랜덤 설명 + faker.address().state(), // 지역 (예: 강원도, 전라남도) + Coordinate.from( + faker.number().randomDouble(8, 126, 130), + faker.number().randomDouble(8, 34, 38) + ) + ); + } + +/* public static List googleApiResponse() { + Coordinate c1 = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate c2 = Coordinate.from(126.8834795656736, 37.351812431636645); + Coordinate c3 = Coordinate.from(126.903150731652, 37.366183537311); + Coordinate c4 = Coordinate.from(126.848208105819, 37.3649832880928); + Coordinate c5 = Coordinate.from(126.897131767179, 37.3693104219685); + + return List.of( + // origin: c1 + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("2.769 km", 2769L), + new GoogleDistanceMatrixResponse.Duration("24분", 1440L)), + c1, c2), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("18.793 km", 18793L), + new GoogleDistanceMatrixResponse.Duration("122분", 7320L)), + c1, c3), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("5.39 km", 5390L), + new GoogleDistanceMatrixResponse.Duration("55분", 3300L)), + c1, c4), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("2.555 km", 2555L), + new GoogleDistanceMatrixResponse.Duration("38분", 2280L)), + c1, c5), + + // origin: c2 + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("2.762 km", 2762L), + new GoogleDistanceMatrixResponse.Duration("24분", 1440L)), + c2, c1), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("12.709 km", 12709L), + new GoogleDistanceMatrixResponse.Duration("104분", 6240L)), + c2, c3), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("7.724 km", 7724L), + new GoogleDistanceMatrixResponse.Duration("73분", 4380L)), + c2, c4), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("4.563 km", 4563L), + new GoogleDistanceMatrixResponse.Duration("53분", 3180L)), + c2, c5), + + // origin: c3 + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("20.308 km", 20308L), + new GoogleDistanceMatrixResponse.Duration("101분", 6060L)), + c3, c1), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("19.463 km", 19463L), + new GoogleDistanceMatrixResponse.Duration("109분", 6540L)), + c3, c2), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("22.867 km", 22867L), + new GoogleDistanceMatrixResponse.Duration("107분", 6420L)), + c3, c4), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("13.475 km", 13475L), + new GoogleDistanceMatrixResponse.Duration("99분", 5940L)), + c3, c5), + + // origin: c4 + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("5.152 km", 5152L), + new GoogleDistanceMatrixResponse.Duration("54분", 3240L)), + c4, c1), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("7.827 km", 7827L), + new GoogleDistanceMatrixResponse.Duration("73분", 4380L)), + c4, c2), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("23.228 km", 23228L), + new GoogleDistanceMatrixResponse.Duration("109분", 6540L)), + c4, c3), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("7.253 km", 7253L), + new GoogleDistanceMatrixResponse.Duration("85분", 5100L)), + c4, c5), + + // origin: c5 + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("2.831 km", 2831L), + new GoogleDistanceMatrixResponse.Duration("37분", 2220L)), + c5, c1), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("4.568 km", 4568L), + new GoogleDistanceMatrixResponse.Duration("54분", 3240L)), + c5, c2), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("12.748 km", 12748L), + new GoogleDistanceMatrixResponse.Duration("96분", 5760L)), + c5, c3), + RouteMetrics.of(new GoogleDistanceMatrixResponse.Element( + new GoogleDistanceMatrixResponse.Distance("8.514 km", 8514L), + new GoogleDistanceMatrixResponse.Duration("69분", 4140L)), + c5, c4) + ); + }*/ + + public static List locations(int numOfLocations) { + return IntStream.range(0, numOfLocations) + .mapToObj(i -> location()) + .toList(); + } + + public static Location location() { + return Location.of( + faker.funnyName().name(), + Coordinate.from( + faker.number().randomDouble(8, 126, 130), + faker.number().randomDouble(8, 34, 38) + )); + } + +} diff --git a/src/test/java/com/example/mohago_nocar/support/IntegrationTestSupport.java b/src/test/java/com/example/mohago_nocar/support/IntegrationTestSupport.java new file mode 100644 index 0000000..d8d7aa9 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/support/IntegrationTestSupport.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.support; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class IntegrationTestSupport { +} diff --git a/src/test/java/com/example/mohago_nocar/support/LocalIntegrationTestSupport.java b/src/test/java/com/example/mohago_nocar/support/LocalIntegrationTestSupport.java new file mode 100644 index 0000000..3ce7e5a --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/support/LocalIntegrationTestSupport.java @@ -0,0 +1,9 @@ +package com.example.mohago_nocar.support; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("dev") +public class LocalIntegrationTestSupport { +} diff --git a/src/test/java/com/example/mohago_nocar/test/DistinctTimeTest.java b/src/test/java/com/example/mohago_nocar/test/DistinctTimeTest.java new file mode 100644 index 0000000..1c0b6dc --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/DistinctTimeTest.java @@ -0,0 +1,72 @@ +package com.example.mohago_nocar.test; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +public class DistinctTimeTest { + + @Test + @DisplayName("나노초 단위로 when 절 수행 시간 측정") + void testMakingDistinctTime(){ + //given + // Coordinate 클래스와 createDestinations() 메서드는 필요합니다. + List destinations = createDestinations(); + + //when + long startNanoTime = System.nanoTime(); // when 절 시작 시간 기록 (나노초) + Map> map = new HashMap<>(); + + for (Coordinate coordinate : destinations) { + map.compute(coordinate, (k, v) -> { + if (v == null) { + // 첫 번째 요소는 식별자 "coordinate1"로 유지 + return new HashSet<>(Set.of("coordinate1")); + } else { + // Key가 있으면 기존 Set에 현재 나노초 시간 추가 + v.add(System.nanoTime()); + return v; + } + }); + } + + long endNanoTime = System.nanoTime(); // when 절 종료 시간 기록 (나노초) + long totalDurationNanos = endNanoTime - startNanoTime; // 총 수행 시간 (나노초) + + // 나노초 단위의 시간을 보기 좋은 포맷으로 변환 및 출력 + long seconds = totalDurationNanos / 1_000_000_000; // 초 (10^9 나노초) + long nanoseconds = totalDurationNanos % 1_000_000_000; // 나머지 나노초 + + System.out.println("---"); + // 초와 나머지 나노초를 함께 출력합니다. (%09d는 나노초를 9자리로 채워서 출력) + System.out.printf("when 절 수행 시간: %d.%09d초 (%d ns)%n", + seconds, nanoseconds, totalDurationNanos); + System.out.println("---"); + + //then + for (Map.Entry> entry : map.entrySet()) { + Coordinate coordinate = entry.getKey(); + Set objects = entry.getValue(); + System.out.println(coordinate); + System.out.println(objects); + } + } + + private Coordinate createFixedCoordinate() { + return Coordinate.from(126.872939584803, 37.3700357495453); + } + + private List createDestinations() { + Coordinate dest1 = Coordinate.from(126.8834795656736, 37.351812431636645); + Coordinate dest2 = Coordinate.from(126.899445340496, 37.3673238473972); + Coordinate dest3 = Coordinate.from(126.848208105819, 37.3649832880928); + Coordinate dest4 = createFixedCoordinate(); + Coordinate dest5 = createFixedCoordinate(); + + return List.of(dest1, dest2, dest3, dest4, dest5); + } + +} diff --git a/src/test/java/com/example/mohago_nocar/test/v2/ESSubmitterTest.java b/src/test/java/com/example/mohago_nocar/test/v2/ESSubmitterTest.java new file mode 100644 index 0000000..1ecd644 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/v2/ESSubmitterTest.java @@ -0,0 +1,33 @@ +package com.example.mohago_nocar.test.v2; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; + +class ESSubmitterTest extends IntegrationTestSupport { + + @Autowired + ESSubmitter esSubmitter; + + @Test + @DisplayName("ExecutorService Task에서 예외를 던지면 ?된다!") + void test() { + esSubmitter.submit(); + esSubmitter.submit(); + esSubmitter.submit(); + + sleep(3); + } + + private void sleep(int seconds) { + try { + Thread.sleep(Duration.ofSeconds(seconds)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/test/v3/LockSupportBasedRateLimiterTest.java b/src/test/java/com/example/mohago_nocar/test/v3/LockSupportBasedRateLimiterTest.java new file mode 100644 index 0000000..f7f3b86 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/v3/LockSupportBasedRateLimiterTest.java @@ -0,0 +1,25 @@ +package com.example.mohago_nocar.test.v3; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class LockSupportBasedRateLimiterTest { + + LockSupportBasedRateLimiter rateLimiter = new LockSupportBasedRateLimiter(); + + @Test + @DisplayName("") + void test() { + //given + + //when + for (int i = 0; i < 5; i++) { + rateLimiter.execute(); + } + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/test/v3/RateLimiterMethodTest.java b/src/test/java/com/example/mohago_nocar/test/v3/RateLimiterMethodTest.java new file mode 100644 index 0000000..2c3692f --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/v3/RateLimiterMethodTest.java @@ -0,0 +1,46 @@ +package com.example.mohago_nocar.test.v3; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +class RateLimiterMethodTest extends IntegrationTestSupport { + + @Autowired + private RateLimiterMethod rateLimiterMethod; + + @Test + @DisplayName("두 메서드 동작이 동일함을 확인한다.") + void test() throws InterruptedException { + //given + + //when + for (int i = 0; i < 5; i++) { + rateLimiterMethod.experimentalRateLimitMethod(i); + } + + //then + Thread.sleep(Duration.ofSeconds(5)); + } + + @Test + @DisplayName("") + void test2() throws InterruptedException { + //given + + //when + for (int i = 0; i < 5; i++) { + rateLimiterMethod.print(); + } + + //then + Thread.sleep(Duration.ofSeconds(5)); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/test/v3/StrictRateLimiterTest.java b/src/test/java/com/example/mohago_nocar/test/v3/StrictRateLimiterTest.java new file mode 100644 index 0000000..17d91af --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/test/v3/StrictRateLimiterTest.java @@ -0,0 +1,29 @@ +package com.example.mohago_nocar.test.v3; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.junit.jupiter.api.Assertions.*; + +class StrictRateLimiterTest extends IntegrationTestSupport { + + @Autowired + private StrictRateLimiter strictRateLimiter; + + @Test + @DisplayName("") + void test(){ + //given + + //when + for (int i = 0; i < 5; i++) { + strictRateLimiter.executeWithRateLimit(); + } + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/LuaScriptTest.java b/src/test/java/com/example/mohago_nocar/transit/LuaScriptTest.java new file mode 100644 index 0000000..68600c2 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/LuaScriptTest.java @@ -0,0 +1,80 @@ +package com.example.mohago_nocar.transit; + +import com.example.mohago_nocar.support.IntegrationTestSupport; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; + +// key:batchId, value: route 저장 +// batch의 completedTaskCount+1 update +// batch가 완료되었다면 batch status 변경 +// batch entry를 ack + delete +// batch status 반환 +public class LuaScriptTest extends IntegrationTestSupport { + + @Autowired + private StringRedisTemplate redisTemplate; + + private static final String ZSET_KEY = "lua:zset:test"; + + @Test + @DisplayName("응답 정보를 저장할 sorted set을 사용해본다") + void shouldUseSortedSet(){ + //given + String lua = + "local key = KEYS[1]\n" + + "local score = tonumber(ARGV[1])\n" + + "local member = ARGV[2]\n" + + "return redis.call('ZADD', key, score, member)"; + + DefaultRedisScript script = new DefaultRedisScript<>(lua, Long.class); + Long result = redisTemplate.execute(script, List.of(ZSET_KEY), "10", "coffee"); + + assertThat(result).isEqualTo(1L); + assertThat(redisTemplate.opsForZSet().range(ZSET_KEY, 0, -1)).isEqualTo(Set.of("coffee")); + + //when + + //then + + } + + @Test + @DisplayName("배치 정보가 저장된 해시를 사용해본다") + void shouldUseHash(){ + //given + String lua = + "local key = KEYS[1]\n" + + "local count = tonumber(ARGV[1])\n" + + "local lastId = ARGV[2]\n" + + "return redis.call('XREAD', 'COUNT', count, 'STREAMS', key, lastId)"; + + DefaultRedisScript script = new DefaultRedisScript<>(lua, List.class); + + //when + List mystream = redisTemplate.execute(script, List.of("mystream"), "2", "0"); + + //then + System.out.println(mystream); + } + + @Test + @DisplayName("스트림 엔트리에 ack과 delete를 사용해본다") + void shouldUseStream(){ + //given + + //when + + //then + + } + +} diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java new file mode 100644 index 0000000..8c977c0 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/GoogleDistanceMatrixApiAdapterTest.java @@ -0,0 +1,108 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.domain.model.RouteMetrics; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.GoogleApiClient; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixStatus; +import com.example.mohago_nocar.transit.infrastructure.error.exception.DistanceMatrixException; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("test") +class GoogleDistanceMatrixApiAdapterTest { + + @Autowired + private GoogleDistanceMatrixApiAdapter googleDistanceMatrixApiAdapter; + + @SpyBean + private GoogleApiClient googleApiClient; + + @Test + @DisplayName("출발지와 도착지의 경위도가 동일해도 정상적으로 응답한다.") + void shouldReturnSuccessResponseWhenSameCoordinateInputs(){ + //given + Coordinate origin = createFixedCoordinate(); + List destinations = IntStream.range(0, 3) + .mapToObj(i -> createFixedCoordinate()) + .toList(); + + //when & then + List routeMetrics = googleDistanceMatrixApiAdapter.getDistanceAndDuration(origin, destinations); + System.out.println(routeMetrics); + } + + private Coordinate createFixedCoordinate() { + return Coordinate.from(126.872939584803, 37.3700357495453); + } + + @DisplayName("유효하지 않은 API 응답임이 확인되면 예외를 throw 한다.") + @Test + public void getDistanceAndDuration_throwException_if_invalid() { + //given + Coordinate origin = createFixedCoordinate(); + List destinations = createDestinations(); + + when(googleApiClient.getDistanceMatrix(origin, destinations)) + .thenReturn(new GoogleDistanceMatrixResponse(null, null, null, GoogleDistanceMatrixStatus.INVALID_REQUEST)); + + //when //then + assertThatThrownBy(() -> googleDistanceMatrixApiAdapter.getDistanceAndDuration(origin, destinations)) + .isInstanceOf(DistanceMatrixException.class) + .hasMessage("잘못된 요청입니다"); + } + + @DisplayName("API 응답에 NULL이 포함되어 있으면 예외를 throw 한다.") + @Test + public void getDistanceAndDuration_catchException_if_null() { + //given + Coordinate origin = createFixedCoordinate(); + List destinations = createDestinations(); + + when(googleApiClient.getDistanceMatrix(origin, destinations)) + .thenReturn(new GoogleDistanceMatrixResponse(null, null, null, GoogleDistanceMatrixStatus.OK)); + + //when //then + assertThatThrownBy(() -> googleDistanceMatrixApiAdapter.getDistanceAndDuration(origin, destinations)) + .isInstanceOf(DistanceMatrixException.class) + .hasMessage("외부 서버 오류가 발생했습니다"); + + } + + @DisplayName("출발지와 도착지 간의 이동 거리와 시간을 알 수 있다.") + @Test + public void getDistanceAndDuration(){ + //given + Coordinate origin = createFixedCoordinate(); + List destinations = createDestinations(); + + //when + List routeMetrics = googleDistanceMatrixApiAdapter.getDistanceAndDuration(origin, destinations); + + //then + Assertions.assertThat(routeMetrics).isNotNull(); + + } + + private List createDestinations() { + Coordinate dest1 = Coordinate.from(126.8834795656736, 37.351812431636645); + Coordinate dest2 = Coordinate.from(126.899445340496, 37.3673238473972); + Coordinate dest3 = Coordinate.from(126.848208105819, 37.3649832880928); + + return List.of(dest1, dest2, dest3); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java new file mode 100644 index 0000000..b5180ec --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/distanceDuration/google/GoogleApiClientTest.java @@ -0,0 +1,59 @@ +package com.example.mohago_nocar.transit.infrastructure.distanceDuration.google; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.distanceDuration.google.dto.response.GoogleDistanceMatrixResponse; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + + +@SpringBootTest +@ActiveProfiles("test") +class GoogleApiClientTest { + + @Autowired + private GoogleApiClient googleApiClient; + + @DisplayName("출발지와 도착지 사이의 거리 및 이동 시간을 조회한다.") + @Test + public void getDistanceMatrix() { + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + + Coordinate dest1 = Coordinate.from(126.8834795656736, 37.351812431636645); + Coordinate dest2 = Coordinate.from(126.899445340496, 37.3673238473972); + Coordinate dest3 = Coordinate.from(126.848208105819, 37.3649832880928); + + List destinations = List.of(dest1, dest2, dest3); + + //when + List> futures = new ArrayList<>(); + + for (int i = 0; i < 4; i++) { + CompletableFuture future = CompletableFuture.supplyAsync( + () -> googleApiClient.getDistanceMatrix(origin, destinations)); + futures.add(future); + } + + + +/* GoogleDistanceMatrixResponse response = googleApiClient.getDistanceMatrix(origin, destinations); + + //then + Assertions.assertThat(response).isNotNull(); + Assertions.assertThat(response.rows().get(0).elements().size()).isEqualTo(3); + + for (GoogleDistanceMatrixResponse.Element element : response.rows().get(0).elements()) { + Assertions.assertThat(element.distance().text()).isNotNull(); + Assertions.assertThat(element.duration().text()).isNotNull(); + }*/ + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java new file mode 100644 index 0000000..8b9a5c6 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/ODsayTransitRouteApiAdapterTest.java @@ -0,0 +1,101 @@ +package com.example.mohago_nocar.transit.infrastructure.route; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.plan.domain.model.Location; +import com.example.mohago_nocar.transit.domain.model.TransitRoute; +import com.example.mohago_nocar.transit.domain.model.WalkPath; +import com.example.mohago_nocar.transit.infrastructure.error.exception.ODsayRouteException; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiRateLimitedClient; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayTransitRouteApiAdapter; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteValidResponse; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@SpringBootTest +@ActiveProfiles("test") +class ODsayTransitRouteApiAdapterTest { + + @Autowired + ODsayTransitRouteApiAdapter adapter; + + @MockBean + ODsayApiRateLimitedClient odsayApiClient; + + @DisplayName("odsay API가 유효한 응답을 주면 TransitRoute 객체로 변환한다.") + @Test + public void getTransitRouteBetweenLocations_whenODsayReturnValidResponse() { + //given + Location origin = Location.of("출발지", Coordinate.from(126.872939584803, 37.3700357495453)); + Location dest = Location.of("도착지", Coordinate.from(126.8834795656736, 37.351812431636645)); + + ODsayRouteValidResponse mockValidResponse = mock(ODsayRouteValidResponse.class); + when(mockValidResponse.isValid()).thenReturn(true); + when(mockValidResponse.getSubPaths()).thenReturn(List.of()); + when(odsayApiClient.searchTransitRoute(origin.getCoordinate(), dest.getCoordinate()) + ).thenReturn(mockValidResponse); + + //when + TransitRoute transitRoute = adapter.getTransitRouteBetweenLocations(origin, dest); + System.out.println(transitRoute); + + //then + assertThat(transitRoute).isNotNull(); + verify(odsayApiClient).searchTransitRoute(origin.getCoordinate(), dest.getCoordinate()); + } + + @DisplayName("odsay API가 유효하지 않은 응답을 주면 예외를 발생시킨다.") + @Test + public void getTransitRouteBetweenLocations_whenODsayReturnInvalidResponse(){ + //given + Location origin = Location.of("출발지", Coordinate.from(126.872939584803, 37.3700357495453)); + Location dest = Location.of("도착지", Coordinate.from(126.8834795656736, 37.351812431636645)); + + ODsayRouteInvalidResponse mockInvalidResponse = mock(ODsayRouteInvalidResponse.class); + when(mockInvalidResponse.isValid()).thenReturn(false); + when(mockInvalidResponse.getErrorCode()).thenReturn("500"); + + when(odsayApiClient.searchTransitRoute(origin.getCoordinate(), dest.getCoordinate()) + ).thenReturn(mockInvalidResponse); + + //when //then + assertThatThrownBy(()-> adapter.getTransitRouteBetweenLocations(origin, dest)) + .isInstanceOf(ODsayRouteException.class) + .hasMessage("ODsay 서버에 오류가 발생하였습니다. ODsay API를 확인해주세요."); + } + + @DisplayName("ODsay API가 Distance Error 응답을 주면 도보 이동 경로를 생성한다.") + @Test + public void getTransitRouteBetweenLocations_whenODsayReturnShortDistanceError(){ + //given + Location origin = Location.of("출발지", Coordinate.from(126.872939584803, 37.3700357495453)); + Location dest = Location.of("도착지", Coordinate.from(126.872939584803, 37.3700357495453)); + + ODsayRouteInvalidResponse mockInvalidResponse = mock(ODsayRouteInvalidResponse.class); + when(mockInvalidResponse.isValid()).thenReturn(false); + when(mockInvalidResponse.getErrorCode()).thenReturn("-98"); + + when(odsayApiClient.searchTransitRoute(origin.getCoordinate(), dest.getCoordinate()) + ).thenReturn(mockInvalidResponse); + + //when + TransitRoute transitRoute = adapter.getTransitRouteBetweenLocations(origin, dest); + System.out.println(transitRoute); + + //then + Assertions.assertThat(transitRoute.getSubPaths().get(0)) + .isInstanceOf(WalkPath.class); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClientTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClientTest.java new file mode 100644 index 0000000..def2dcf --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitedClientTest.java @@ -0,0 +1,68 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteValidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class ODsayApiRateLimitedClientTest { + + @Autowired + private ODsayApiRateLimitedClient odsayApiClient; + + @DisplayName("출발지와 도착지 간의 대중교통 경로를 조회할 수 있다.") + @Test + public void searchTransitRoute(){ + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate dest = Coordinate.from(126.8834795656736, 37.351812431636645); + + //when + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute(origin, dest); + + //then + assertThat(response).isInstanceOf(ODsayTransitRouteResponse.class); + assertThat(response).isInstanceOf(ODsayRouteValidResponse.class); + } + + @DisplayName("출발지와 도착지 간의 거리가 700m 이내이면 유효하지 않은 응답이 반환된다.") + @Test + public void searchTransitRoute_whenDistanceLessThan700Meter(){ + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate dest = Coordinate.from(126.872939584803, 37.3700357495453); + + //when + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute(origin, dest); + + //then + assertThat(response).isInstanceOf(ODsayTransitRouteResponse.class); + assertThat(response).isInstanceOf(ODsayRouteInvalidResponse.class); + } + +/* @DisplayName("호출 간격을 준수하며 API 요청을 보낸다.") + @Test + public void searchTransitRoute_withRateLimit(){ + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate dest = Coordinate.from(126.8834795656736, 37.351812431636645); + + //when //then + List responses = IntStream.range(0, 10) + .mapToObj(i -> odsayApiClient.searchTransitRoute(origin, dest)) + .filter(response -> response instanceof ODsayRouteValidResponse) + .toList(); + + assertThat(responses).hasSize(10); + }*/ + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitingTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitingTest.java new file mode 100644 index 0000000..16a3529 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/odsay/ODsayApiRateLimitingTest.java @@ -0,0 +1,75 @@ +package com.example.mohago_nocar.transit.infrastructure.route.odsay; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.support.LocalIntegrationTestSupport; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.deprecated.ODsayApiClient; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayRouteInvalidResponse; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +class ODsayApiRateLimitingTest extends LocalIntegrationTestSupport { + + @Autowired + private ODsayApiClient odsayApiClient; + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.KOREAN); + + ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + @Test + @DisplayName("속도를 측정한다.") + void testLimit() throws InterruptedException { + + AtomicInteger count = new AtomicInteger(0); +/* for (int i = 0; i < 20; i++) { + int reqOrder = i; + CompletableFuture.runAsync(() -> { + System.out.println(reqOrder + ": 요청, 시각: " + formatter.format(LocalDateTime.now())); + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute( + Coordinate.from(126.98708591399983, 37.56127528907461), + Coordinate.from(126.99023335682591, 37.55377929365595) + ); + if (response instanceof ODsayRouteInvalidResponse) { + System.out.println(reqOrder + ": 실패"); + } else { + System.out.println(reqOrder + ": 성공"); + } + }); + }*/ + scheduler.scheduleAtFixedRate(() -> { + int reqOrder = count.incrementAndGet(); + + CompletableFuture.runAsync(() -> { + System.out.println(reqOrder + ": 요청, 시각: " + formatter.format(LocalDateTime.now())); + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute( + Coordinate.from(126.98708591399983, 37.56127528907461), + Coordinate.from(126.99023335682591, 37.55377929365595) + ); + if (response instanceof ODsayRouteInvalidResponse) { + System.out.println(reqOrder + ": 실패"); + } else { + System.out.println(reqOrder + ": 성공"); + } + }); + + }, 0, 200, TimeUnit.MILLISECONDS); + + Thread.sleep(Duration.ofSeconds(2)); + + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/process/StreamPendingHandlerTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/process/StreamPendingHandlerTest.java new file mode 100644 index 0000000..75b15e6 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/process/StreamPendingHandlerTest.java @@ -0,0 +1,58 @@ +package com.example.mohago_nocar.transit.infrastructure.route.process; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class StreamPendingHandlerTest { + + @Test + @DisplayName("비정상적으로 오래 걸리는 경우 서버 크러쉬로 간주하고 재처리를 시도한다") + void t1(){ + //given + // 요청 전송 + + //when + // 파트 1인데 10초 걸려 + // 파트 2인데 타임아웃 시간보다 더 걸려 + // -> 결과 저장되어 있으면 알림 전송 후 ack처리할지도? -> 알림이 이미 전송되어있으면 어카냐고요 + + //then + // 재시도큐로 전송됨 + } + + @Test + @DisplayName("API 응답 타임아웃 에러가 발생한 경우 실패 처리한다") + void t2(){ + //given + + //when + + //then + + } + + @Test + @DisplayName("실패 표기된 배치 발견 시 실패 알림을 전송한다") + void t3(){ + //given + + //when + + //then + + } + + @Test + @DisplayName("외부 API 서버 내부 원인인 경우 에러 메시지를 로깅한다") + void t4(){ + //given + + //when + + //then + + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/test/TestConsumerTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/test/TestConsumerTest.java new file mode 100644 index 0000000..d5feea7 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/test/TestConsumerTest.java @@ -0,0 +1,170 @@ +package com.example.mohago_nocar.transit.infrastructure.route.test; + +import com.example.mohago_nocar.support.IntegrationTestSupport; + +class TestConsumerTest extends IntegrationTestSupport { + +/* + @Autowired + TestProducer testProducer; + + @Autowired + TestConsumer testConsumer; + + @Test + @DisplayName("기본 설정 스레드 동작을 확인한다") + void test() throws InterruptedException { + //given + int requestThreads = 3; + ExecutorService executor = Executors.newFixedThreadPool(requestThreads); + CountDownLatch countDownLatch = new CountDownLatch(requestThreads); + + // when + for (int i = 1; i <= requestThreads; i++) { + final int index = i; + + executor.submit(() -> { + try { + testProducer.produce(index + "번째 메시지임둥"); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + countDownLatch.countDown(); + } + }); + } + + countDownLatch.await(); + + //then + Thread.sleep(Duration.ofSeconds(10)); + } + + @Test + @DisplayName("스트림 리스너 컨테이너에 executor를 지정하는 경우 동작을 확인한다") + void test2() throws InterruptedException { + //given + + //when + + //then + + } + + @Test + @DisplayName("예외 객체 메서드를 사용해봐요") + void test3(){ + //given + try { + throw new CustomException(GlobalStatus.ENTITY_NOT_FOUND); + } catch (CustomException e) { + System.out.println("getMessage:" + e.getMessage()); + System.out.println("getCause:" + e.getCause()); + List topStackTrace = getTopStackTrace(e, 10); + for (String s : topStackTrace) { + System.out.println(s); + } + } + + //when + + //then + + } + + @Test + @DisplayName("") + void test4() throws InterruptedException { + //given + + //when + for (int i = 0; i < 10; i++) { + testProducer.produce(i + "번째 메시지임둥"); + } + + //then + Thread.sleep(Duration.ofSeconds(1)); + testConsumer.destroy(); + Thread.sleep(Duration.ofSeconds(10)); + } + + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.KOREAN); + + @Test +// @DisplayName("New가 있어도 PEL부터 읽는다는게 사실인지 확인한다 -> 구라임") + @DisplayName("Completable Future timeout 기능 확인해봐요") + void test6(){ + //given + CompletableFuture future1 = CompletableFuture.supplyAsync( + () -> { + try { + Thread.sleep(Duration.ofSeconds(3)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + System.out.println("안녕? "); + return "안녕? "; + }); + + CompletableFuture future2 = future1 + .thenApply(s -> { + try { + Thread.sleep(Duration.ofSeconds(1)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + System.out.println("응 ~ 안녕"); + return s + "응 ~ 안녕"; + }) + .thenApply(s -> { + try { + Thread.sleep(Duration.ofSeconds(3)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return s; + }); + + //when + + try { + String result = future2.orTimeout(2, SECONDS).get(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + + + //then + + } + + @Test + @DisplayName("") + void test5(){ + //given + + //when + for (int i = 0; i < 5; i++) { + System.out.println(formatter.format(LocalDateTime.now())); + LockSupport.parkNanos(SECONDS.toNanos(1)); + System.out.println(formatter.format(LocalDateTime.now())); + + } + + //then + + } + + private static List getTopStackTrace(Throwable ex, int limit) { + return Arrays.stream(ex.getStackTrace()) + .limit(limit) + .map(StackTraceElement::toString) + .collect(Collectors.toList()); + } +*/ + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedApiKeyPoolTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedApiKeyPoolTest.java new file mode 100644 index 0000000..278c837 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedApiKeyPoolTest.java @@ -0,0 +1,125 @@ +package com.example.mohago_nocar.transit.infrastructure.route.v2; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BandwidthBuilder; +import io.github.bucket4j.Bucket; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RateLimitedApiKeyPoolTest { + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.KOREAN); + + + @Test + @DisplayName("") + void shouldNotEnsureFirstInputFirstOut() throws InterruptedException { + // given + Bucket bucket = createBucket(); + ExecutorService executor = Executors.newFixedThreadPool(15); + List executionOrder = Collections.synchronizedList(new ArrayList<>()); + + // when: 동시에 여러 스레드가 토큰 요청 + CountDownLatch latch = new CountDownLatch(1); + for (int i = 1; i <= 15; i++) { + final int id = i; + executor.submit(() -> { + try { + latch.await(); // 동시에 시작 + bucket.asBlocking().consume(1); // 토큰 획득 (대기 가능) + executionOrder.add(id); // 획득한 순서 기록 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + latch.countDown(); // 모든 스레드 동시에 시작 + executor.shutdown(); + executor.awaitTermination(15, TimeUnit.SECONDS); + + // then: 요청 순서(1,2,3,4,5)와 실제 획득 순서가 다를 수 있음 + System.out.println("Execution order: " + executionOrder); + + assertThat(executionOrder).isNotEqualTo(List.of(1, 2, 3, 4, 5)); + } + + private Bucket createBucket() { + Bandwidth limit = BandwidthBuilder.builder().capacity(5) + .refillIntervally(5, Duration.ofSeconds(1)) + .initialTokens(0) + .build(); + + return Bucket.builder() + .addLimit(limit) + .build(); + } + +/* @Test + @DisplayName("요청 순서를 올바르게 카운팅한다") + void shouldHaveRaceConditionWhenPlainInteger() throws InterruptedException, NoSuchFieldException, IllegalAccessException { + //given +// RateLimitedApiKeyPool pool = new RateLimitedApiKeyPool(fakeProperties(), () -> createBucket()); + + RateLimitedApiKeyPool pool = new RateLimitedApiKeyPoolConfig().rateLimitedOdsayApiKeyPool(fakeProperties()); + int threadCount = 20; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + List> futures = new ArrayList<>(); + + //when + for (int i = 0; i < threadCount; i++) { + futures.add(executor.submit(() -> { + String key = pool.acquireEncodedKey(); + System.out.println(formatter.format(LocalDateTime.now()) + + ":" + key); + return key; + })); + } + + executor.shutdown(); + executor.awaitTermination(5, TimeUnit.SECONDS); + + //then + int nextOrder = pool.getNextOrder(); + System.out.println("final order = " + nextOrder); + }*/ + + @Test + @DisplayName("") + void shouldGetAndIncrement(){ + //given + AtomicInteger atomicInteger = new AtomicInteger(); + + //when + for (int i = 0; i < 20; i++) { + int result = atomicInteger.getAndIncrement() % 2; + System.out.println(i + "번째 요청의 결과:" + result); + } + + //then + } + + private Bucket mockBucket() { + Bandwidth limit = BandwidthBuilder.builder() + .capacity(Long.MAX_VALUE) + .refillIntervally(1, Duration.ofNanos(1)) + .build(); + + return Bucket.builder() + .addLimit(limit) + .build(); + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedODsayApiRateLimitedClientTest.java b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedODsayApiRateLimitedClientTest.java new file mode 100644 index 0000000..89ab093 --- /dev/null +++ b/src/test/java/com/example/mohago_nocar/transit/infrastructure/route/v2/RateLimitedODsayApiRateLimitedClientTest.java @@ -0,0 +1,57 @@ +package com.example.mohago_nocar.transit.infrastructure.route.v2; + +import com.example.mohago_nocar.global.common.domain.vo.Coordinate; +import com.example.mohago_nocar.support.IntegrationTestSupport; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.ODsayApiRateLimitedClient; +import com.example.mohago_nocar.transit.infrastructure.route.odsay.response.ODsayTransitRouteResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; + +class RateLimitedODsayApiRateLimitedClientTest extends IntegrationTestSupport { + + @Autowired + private ODsayApiRateLimitedClient odsayApiClient; + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS", Locale.KOREAN); + + @DisplayName("출발지와 도착지 간의 대중교통 경로를 429에러 없이 조회한다.") + @Test + public void safeSearchTransitRoute() throws InterruptedException { + System.out.println("아!아! 마이크테스트! 시작합니다- : " + formatter.format(LocalDateTime.now())); + + List> futures = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + CompletableFuture future = + CompletableFuture.runAsync(() -> callAPI()); + futures.add(future); + } + + // 모든 작업이 완료될 때까지 대기 + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + System.out.println("끝났어요!: " + formatter.format(LocalDateTime.now())); + } + + private void callAPI() { + //given + Coordinate origin = Coordinate.from(126.872939584803, 37.3700357495453); + Coordinate dest = Coordinate.from(126.8834795656736, 37.351812431636645); + + //when + ODsayTransitRouteResponse response = odsayApiClient.searchTransitRoute(origin, dest); + + //then + System.out.println(response); + } + +} \ No newline at end of file diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..43e6b5b --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + logs/app/error.log + + + ERROR + ACCEPT + DENY + + + + originalEvent + throwableClass + throwableMessage + throwableType + rootCause + stackTrace + + + + logs/app/%d{yyyy-MM-dd}-error.log + 90 + 5GB + + + + + + logs/app/warn.log + + + WARN + ACCEPT + DENY + + + + originalEvent + throwableClass + throwableMessage + throwableType + + + + logs/app/%d{yyyy-MM-dd}-warn.log + 90 + 5GB + + + + + + + + + + + \ No newline at end of file