diff --git a/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java b/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java index 51dd5bea1..0271ab668 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusApi.java @@ -11,6 +11,7 @@ import in.koreatech.koin.domain.bus.dto.BusCourseResponse; import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; +import in.koreatech.koin.domain.bus.model.BusTimetable; import in.koreatech.koin.domain.bus.model.enums.BusStation; import in.koreatech.koin.domain.bus.model.enums.BusType; import io.swagger.v3.oas.annotations.Operation; @@ -39,6 +40,14 @@ ResponseEntity getBusRemainTime( @Parameter(description = "koreatech, station, terminal") @RequestParam BusStation arrival ); + @Operation(summary = "버스 시간표 조회") + @GetMapping("/timetable") + ResponseEntity> getBusTimetable( + @RequestParam(value = "bus_type") BusType busType, + @RequestParam(value = "direction") String direction, + @RequestParam(value = "region") String region + ); + @ApiResponses( value = { @ApiResponse(responseCode = "200"), diff --git a/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java b/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java index 0a42d932f..1ed049d62 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java +++ b/src/main/java/in/koreatech/koin/domain/bus/controller/BusController.java @@ -13,6 +13,7 @@ import in.koreatech.koin.domain.bus.dto.BusCourseResponse; import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; +import in.koreatech.koin.domain.bus.model.BusTimetable; import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; import in.koreatech.koin.domain.bus.model.enums.BusStation; import in.koreatech.koin.domain.bus.model.enums.BusType; @@ -36,6 +37,15 @@ public ResponseEntity getBusRemainTime( return ResponseEntity.ok().body(busRemainTime); } + @GetMapping("/timetable") + public ResponseEntity> getBusTimetable( + @RequestParam(value = "bus_type") BusType busType, + @RequestParam(value = "direction") String direction, + @RequestParam(value = "region") String region + ){ + return ResponseEntity.ok().body(busService.getBusTimetable(busType, direction, region)); + } + @GetMapping("/courses") public ResponseEntity> getBusCourses() { return ResponseEntity.ok().body(busService.getBusCourses()); diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/ExpressBusTimeTable.java b/src/main/java/in/koreatech/koin/domain/bus/dto/ExpressBusTimeTable.java deleted file mode 100644 index e6545529c..000000000 --- a/src/main/java/in/koreatech/koin/domain/bus/dto/ExpressBusTimeTable.java +++ /dev/null @@ -1,29 +0,0 @@ -package in.koreatech.koin.domain.bus.dto; - -import static java.time.format.DateTimeFormatter.ofPattern; - -import java.time.LocalTime; - -import in.koreatech.koin.domain.bus.model.express.ExpressBusCacheInfo; -import in.koreatech.koin.domain.bus.model.express.OpenApiExpressBusArrival; - -public record ExpressBusTimeTable( - LocalTime depart, - LocalTime arrival, - int charge -) { - - public static ExpressBusTimeTable from(OpenApiExpressBusArrival openApiExpressBusArrival) { - LocalTime departure = LocalTime.parse(openApiExpressBusArrival.depPlandTime(), ofPattern("yyyyMMddHHmm")); - LocalTime arrival = LocalTime.parse(openApiExpressBusArrival.arrPlandTime(), ofPattern("yyyyMMddHHmm")); - int charge = openApiExpressBusArrival.charge(); - return new ExpressBusTimeTable(departure, arrival, charge); - } - - public static ExpressBusTimeTable from(ExpressBusCacheInfo expressBusCacheInfo) { - LocalTime departure = expressBusCacheInfo.departureTime(); - LocalTime arrival = expressBusCacheInfo.arrivalTime(); - int charge = expressBusCacheInfo.charge(); - return new ExpressBusTimeTable(departure, arrival, charge); - } -} diff --git a/src/main/java/in/koreatech/koin/domain/bus/exception/BusTypeNotSupportException.java b/src/main/java/in/koreatech/koin/domain/bus/exception/BusTypeNotSupportException.java new file mode 100644 index 000000000..a53e3bfa0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/exception/BusTypeNotSupportException.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.bus.exception; + +public class BusTypeNotSupportException extends IllegalArgumentException { + private static final String DEFAULT_MESSAGE = "해당 버스타입에는 지원하지 않는 기능입니다."; + + public BusTypeNotSupportException(String message) { + super(message); + } + + public static BusTypeNotSupportException withDetail(String detail) { + String message = String.format("%s %s", DEFAULT_MESSAGE, detail); + return new BusTypeNotSupportException(message); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/BusTimetable.java b/src/main/java/in/koreatech/koin/domain/bus/model/BusTimetable.java new file mode 100644 index 000000000..abc4ef460 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/BusTimetable.java @@ -0,0 +1,4 @@ +package in.koreatech.koin.domain.bus.model; + +public abstract class BusTimetable { +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/SchoolBusTimetable.java b/src/main/java/in/koreatech/koin/domain/bus/model/SchoolBusTimetable.java new file mode 100644 index 000000000..d10b992b9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/SchoolBusTimetable.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.domain.bus.model; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.Getter; + +@Getter +@JsonNaming(value = SnakeCaseStrategy.class) +public class SchoolBusTimetable extends BusTimetable { + private final String routeName; + private final List arrivalInfo; + + public SchoolBusTimetable(String routeName, List arrivalInfo){ + this.routeName = routeName; + this.arrivalInfo = arrivalInfo; + } + + @Getter + public static class ArrivalNode { + private final String nodeName; + private final String arrivalTime; + + public ArrivalNode(String nodeName, String arrivalTime){ + this.nodeName = nodeName; + this.arrivalTime = arrivalTime; + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRemainTime.java b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRemainTime.java index 2ebe64ddf..a6482f859 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRemainTime.java +++ b/src/main/java/in/koreatech/koin/domain/bus/model/city/CityBusRemainTime.java @@ -38,7 +38,7 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { return false; } - CityBusRemainTime that = (CityBusRemainTime) o; + CityBusRemainTime that = (CityBusRemainTime)o; return Objects.equals(getBusArrivalTime(), that.getBusArrivalTime()) && Objects.equals(busNumber, that.busNumber); } diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusCacheInfo.java b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusCacheInfo.java index f831ed9be..15c1334c3 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusCacheInfo.java +++ b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusCacheInfo.java @@ -3,8 +3,8 @@ import java.time.LocalTime; public record ExpressBusCacheInfo( - LocalTime departureTime, - LocalTime arrivalTime, + LocalTime depart, + LocalTime arrival, int charge ) { diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusTimetable.java b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusTimetable.java new file mode 100644 index 000000000..cb37f2ead --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/express/ExpressBusTimetable.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.domain.bus.model.express; + +import java.time.format.DateTimeFormatter; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.BusTimetable; +import lombok.Getter; + +@Getter +@JsonNaming(value = SnakeCaseStrategy.class) +public class ExpressBusTimetable extends BusTimetable { + + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + + private final String depart; + + private final String arrival; + + private final int charge; + + public ExpressBusTimetable(String depart, String arrival, int charge){ + this.depart = depart; + this.arrival = arrival; + this.charge = charge; + } + + public static ExpressBusTimetable from(ExpressBusCacheInfo expressBusCacheInfo){ + String departure = expressBusCacheInfo.depart().format(TIME_FORMATTER); + String arrival = expressBusCacheInfo.arrival().format(TIME_FORMATTER); + int charge = expressBusCacheInfo.charge(); + return new ExpressBusTimetable(departure, arrival, charge); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java index d891a99cd..f6a6ca3ff 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/BusRepository.java @@ -1,9 +1,11 @@ package in.koreatech.koin.domain.bus.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.repository.Repository; +import in.koreatech.koin.domain.bus.exception.BusNotFoundException; import in.koreatech.koin.domain.bus.model.mongo.BusCourse; public interface BusRepository extends Repository { @@ -13,4 +15,11 @@ public interface BusRepository extends Repository { List findAll(); List findByBusType(String busType); + + Optional findByBusTypeAndDirectionAndRegion(String busType, String direction, String region); + + default BusCourse getByBusTypeAndDirectionAndRegion(String busType, String direction, String region) { + return findByBusTypeAndDirectionAndRegion(busType, direction, region).orElseThrow( + () -> BusNotFoundException.withDetail("region")); + } } diff --git a/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java index 3c44eabb4..2372f3880 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java +++ b/src/main/java/in/koreatech/koin/domain/bus/service/BusService.java @@ -18,7 +18,11 @@ import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; import in.koreatech.koin.domain.bus.exception.BusIllegalStationException; +import in.koreatech.koin.domain.bus.exception.BusTypeNotFoundException; +import in.koreatech.koin.domain.bus.exception.BusTypeNotSupportException; import in.koreatech.koin.domain.bus.model.BusRemainTime; +import in.koreatech.koin.domain.bus.model.BusTimetable; +import in.koreatech.koin.domain.bus.model.SchoolBusTimetable; import in.koreatech.koin.domain.bus.model.enums.BusDirection; import in.koreatech.koin.domain.bus.model.enums.BusStation; import in.koreatech.koin.domain.bus.model.enums.BusType; @@ -149,10 +153,34 @@ private void validateBusCourse(BusStation depart, BusStation arrival) { } } + public List getBusTimetable(BusType busType, String direction, String region) { + if (busType == BusType.CITY) { + throw new BusTypeNotSupportException("CITY"); + } + + if (busType == BusType.EXPRESS) { + return expressBusOpenApiClient.getExpressBusTimetable(direction); + } + + if (busType == BusType.SHUTTLE || busType == BusType.COMMUTING) { + BusCourse busCourse = busRepository + .getByBusTypeAndDirectionAndRegion(busType.name().toLowerCase(), direction, region); + + return busCourse.getRoutes().stream() + .map(route -> new SchoolBusTimetable( + route.getRouteName(), + route.getArrivalInfos().stream() + .map(node -> new SchoolBusTimetable.ArrivalNode( + node.getNodeName(), node.getArrivalTime()) + ).toList())).toList(); + } + + throw new BusTypeNotFoundException(busType.name()); + } + public List getBusCourses() { return busRepository.findAll().stream() .map(BusCourseResponse::from) .toList(); } - } diff --git a/src/main/java/in/koreatech/koin/domain/bus/util/ExpressBusOpenApiClient.java b/src/main/java/in/koreatech/koin/domain/bus/util/ExpressBusOpenApiClient.java index 60dab8a1d..296db13a1 100644 --- a/src/main/java/in/koreatech/koin/domain/bus/util/ExpressBusOpenApiClient.java +++ b/src/main/java/in/koreatech/koin/domain/bus/util/ExpressBusOpenApiClient.java @@ -32,16 +32,17 @@ import com.google.gson.reflect.TypeToken; import in.koreatech.koin.domain.bus.dto.ExpressBusRemainTime; -import in.koreatech.koin.domain.bus.dto.ExpressBusTimeTable; import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; import in.koreatech.koin.domain.bus.exception.BusOpenApiException; import in.koreatech.koin.domain.bus.model.BusRemainTime; +import in.koreatech.koin.domain.bus.model.BusTimetable; import in.koreatech.koin.domain.bus.model.enums.BusOpenApiResultCode; import in.koreatech.koin.domain.bus.model.enums.BusStation; import in.koreatech.koin.domain.bus.model.express.ExpressBusCache; import in.koreatech.koin.domain.bus.model.express.ExpressBusCacheInfo; import in.koreatech.koin.domain.bus.model.express.ExpressBusRoute; import in.koreatech.koin.domain.bus.model.express.ExpressBusStationNode; +import in.koreatech.koin.domain.bus.model.express.ExpressBusTimetable; import in.koreatech.koin.domain.bus.model.express.OpenApiExpressBusArrival; import in.koreatech.koin.domain.bus.repository.ExpressBusCacheRepository; import in.koreatech.koin.domain.version.model.Version; @@ -110,24 +111,26 @@ public List getBusRemainTime(BusStation depart, BusStation private void storeRemainTimeByOpenApi(String departName, String arrivalName) { JsonObject busApiResponse = getBusApiResponse(departName, arrivalName); List busArrivals = extractBusArrivalInfo(busApiResponse); - expressBusCacheRepository.save( - ExpressBusCache.of( - new ExpressBusRoute(departName, arrivalName), - // API로 받은 yyyyMMddHHmm 형태의 시간을 HH:mm 형태로 변환하여 Redis에 저장한다. - busArrivals.stream() - .map(it -> new ExpressBusCacheInfo( - LocalTime.parse( - LocalDateTime.parse(it.depPlandTime(), ofPattern("yyyyMMddHHmm")) - .format(ofPattern("HH:mm")) - ), - LocalTime.parse( - LocalDateTime.parse(it.arrPlandTime(), ofPattern("yyyyMMddHHmm")) - .format(ofPattern("HH:mm")) - ), - it.charge() - )) - .toList() - )); + + ExpressBusCache expressBusCache = ExpressBusCache.of( + new ExpressBusRoute(departName, arrivalName), + // API로 받은 yyyyMMddHHmm 형태의 시간을 HH:mm 형태로 변환하여 Redis에 저장한다. + busArrivals.stream() + .map(it -> new ExpressBusCacheInfo( + LocalTime.parse( + LocalDateTime.parse(it.depPlandTime(), ofPattern("yyyyMMddHHmm")) + .format(ofPattern("HH:mm")) + ), + LocalTime.parse( + LocalDateTime.parse(it.arrPlandTime(), ofPattern("yyyyMMddHHmm")) + .format(ofPattern("HH:mm")) + ), + it.charge() + )) + .toList() + ); + + expressBusCacheRepository.save(expressBusCache); versionRepository.getByType(VersionType.EXPRESS).update(clock); } @@ -162,7 +165,7 @@ private URL getBusApiURL(String depart, String arrival) { ExpressBusStationNode arrivalNode = ExpressBusStationNode.from(arrival); StringBuilder urlBuilder = new StringBuilder(OPEN_API_URL); /*URL*/ try { - urlBuilder.append("?" + encode("serviceKey", UTF_8) + "=" + openApiKey); + urlBuilder.append("?" + encode("serviceKey", UTF_8) + "=" + encode(openApiKey, UTF_8)); urlBuilder.append("&" + encode("numOfRows", UTF_8) + "=" + encode("30", UTF_8)); urlBuilder.append("&" + encode("_type", UTF_8) + "=" + encode("json", UTF_8)); urlBuilder.append("&" + encode("depTerminalId", UTF_8) + "=" + encode(departNode.getStationId(), UTF_8)); @@ -204,15 +207,11 @@ private List getStoredRemainTime(String departName, String return Collections.emptyList(); } List busArrivals = expressBusCache.getBusInfos(); - return getExpressBusRemainTime( - busArrivals - .stream() - .map(ExpressBusTimeTable::from) - .toList()); + return getExpressBusRemainTime(busArrivals); } private List getExpressBusRemainTime( - List busArrivals + List busArrivals ) { return busArrivals.stream() .map(it -> new ExpressBusRemainTime(it.depart(), EXPRESS.name().toLowerCase())) @@ -224,4 +223,37 @@ public boolean isCacheExpired(Version version, Clock clock) { return duration.toSeconds() < 0 || Duration.ofHours(ExpressBusCache.getCacheExpireHour()).toSeconds() <= duration.toSeconds(); } + + public List getExpressBusTimetable(String direction) { + Version version = versionRepository.getByType(VersionType.EXPRESS); + String depart = "", arrival = ""; + + if ("from".equals(direction)) { + depart = "koreatech"; + arrival = "terminal"; + } + if ("to".equals(direction)) { + depart = "terminal"; + arrival = "koreatech"; + } + if (depart.isEmpty() || arrival.isEmpty()) { + throw new UnsupportedOperationException(); + } + + if (isCacheExpired(version, clock)) { + storeRemainTimeByOpenApi(depart, arrival); + } + + String busCacheId = ExpressBusCache.generateId(new ExpressBusRoute(depart, arrival)); + ExpressBusCache expressBusCache = expressBusCacheRepository.getById(busCacheId); + if (Objects.isNull(expressBusCache)) { + return Collections.emptyList(); + } + List busArrivals = expressBusCache.getBusInfos(); + + return busArrivals + .stream() + .map(ExpressBusTimetable::from) + .toList(); + } } diff --git a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java index 14fd8cc47..3934ae879 100644 --- a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java @@ -42,6 +42,7 @@ import in.koreatech.koin.domain.bus.repository.ExpressBusCacheRepository; import in.koreatech.koin.domain.version.model.Version; import in.koreatech.koin.domain.version.repository.VersionRepository; +import in.koreatech.koin.support.JsonAssertions; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -71,6 +72,10 @@ class BusApiTest extends AcceptanceTest { void initBusCourse() { final String arrivalTime = "18:10"; + BusType busType = BusType.from("shuttle"); + BusStation depart = BusStation.from("koreatech"); + BusStation arrival = BusStation.from("terminal"); + BusCourse busCourse = BusCourse.builder() .busType("shuttle") .region("천안") @@ -153,6 +158,7 @@ void getNextShuttleBusRemainTime() { ); } + @Test @DisplayName("다음 시내버스까지 남은 시간을 조회한다. - Redis") void getNextCityBusRemainTimeRedis() { @@ -410,4 +416,136 @@ void getSearchTimetable() { } ); } + + @Test + @DisplayName("시내버스 시간표를 조회한다 - 지원하지 않음") + void getCityBusTimetable() { + when(dateTimeProvider.getNow()).thenReturn(Optional.of(UPDATED_AT)); + + BusType busType = BusType.from("city"); + BusStation depart = BusStation.from("terminal"); + BusStation arrival = BusStation.from("koreatech"); + BusDirection direction = BusStation.getDirection(depart, arrival); + + versionRepository.save( + Version.builder() + .version("20240_1711255839") + .type("city_bus_timetable") + .build() + ); + + Instant requestedAt = ZonedDateTime.parse("2024-02-21 21:00:00 KST", ofPattern("yyyy-MM-dd " + "HH:mm:ss z")) + .toInstant(); + + when(clock.instant()).thenReturn(requestedAt); + when(dateTimeProvider.getNow()).thenReturn(Optional.of(requestedAt)); + + String busApiReturnValue = """ + { + "response": { + "header": { + "resultCode": "00", + "resultMsg": "NORMAL SERVICE." + }, + "body": { + "items": { + "item": [ + { + "arrprevstationcnt": 3, + "arrtime": 600, + "nodeid": "CAB285000686", + "nodenm": "종합터미널", + "routeid": "CAB285000003", + "routeno": 400, + "routetp": "일반버스", + "vehicletp": "저상버스" + }, + { + "arrprevstationcnt": 10, + "arrtime": 800, + "nodeid": "CAB285000686", + "nodenm": "종합터미널", + "routeid": "CAB285000024", + "routeno": 405, + "routetp": "일반버스", + "vehicletp": "일반차량" + }, + { + "arrprevstationcnt": 10, + "arrtime": 700, + "nodeid": "CAB285000686", + "nodenm": "종합터미널", + "routeid": "CAB285000024", + "routeno": 200, + "routetp": "일반버스", + "vehicletp": "일반차량" + } + ] + }, + "numOfRows": 30, + "pageNo": 1, + "totalCount": 3 + } + } + } + """; + + ExtractableResponse response = RestAssured + .given() + .when() + .param("bus_type", busType.name().toLowerCase()) + .param("direction", "to") + .param("region", "천안") + .get("/bus/timetable") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract(); + } + + @Test + @DisplayName("셔틀버스 시간표를 조회한다.") + void getShuttleBusTimetable() { + final String arrivalTime = "18:10"; + + BusType busType = BusType.from("shuttle"); + String direction = "from"; + String region = "천안"; + + ExtractableResponse response = RestAssured + .given() + .when() + .param("bus_type", busType.name().toLowerCase()) + .param("direction", direction) + .param("region", region) + .get("/bus/timetable") + .then() + .statusCode(HttpStatus.OK.value()) + .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + [ + { + "routeName": "주중", + "arrivalInfo": [ + { + "nodeName": "한기대", + "arrivalTime": "18:10" + }, + { + "nodeName": "신계초,운전리,연춘리", + "arrivalTime": "정차" + }, + { + "nodeName": "천안역(학화호두과자)", + "arrivalTime": "18:50" + },{ + "nodeName": "터미널(신세계 앞 횡단보도)", + "arrivalTime": "18:55" + } + ] + } + ] + """); + } }