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 cce0832fc..f14fd61a6 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,11 +11,12 @@ import in.koreatech.koin.domain.bus.dto.BusCourseResponse; import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; +import in.koreatech.koin.domain.bus.dto.CityBusTimetableResponse; 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 in.koreatech.koin.domain.bus.model.express.TmoneyOpenApiExpressBusArrival; +import in.koreatech.koin.domain.bus.model.enums.CityBusDirection; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -58,6 +59,13 @@ ResponseEntity getBusTimetableV2( @RequestParam(value = "region") String region ); + @Operation(summary = "시내버스 시간표 조회") + @GetMapping("/timetable/city") + ResponseEntity getCityBusTimetable( + @RequestParam(value = "bus_number") Long bus_number, + @RequestParam(value = "direction") CityBusDirection direction + ); + @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 a1a2ea666..f74ce989f 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 @@ -14,11 +14,12 @@ import in.koreatech.koin.domain.bus.dto.BusCourseResponse; import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; +import in.koreatech.koin.domain.bus.dto.CityBusTimetableResponse; 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 in.koreatech.koin.domain.bus.model.express.TmoneyOpenApiExpressBusArrival; +import in.koreatech.koin.domain.bus.model.enums.CityBusDirection; import in.koreatech.koin.domain.bus.service.BusService; import lombok.RequiredArgsConstructor; @@ -57,6 +58,14 @@ public ResponseEntity getBusTimetableV2( return ResponseEntity.ok().body(busService.getBusTimetableWithUpdatedAt(busType, direction, region)); } + @GetMapping("/timetable/city") + public ResponseEntity getCityBusTimetable( + @RequestParam(value = "bus_number") Long busNumber, + @RequestParam(value = "direction") CityBusDirection direction + ) { + return ResponseEntity.ok().body(busService.getCityBusTimetable(busNumber, direction)); + } + @GetMapping("/courses") public ResponseEntity> getBusCourses() { return ResponseEntity.ok().body(busService.getBusCourses()); diff --git a/src/main/java/in/koreatech/koin/domain/bus/dto/CityBusTimetableResponse.java b/src/main/java/in/koreatech/koin/domain/bus/dto/CityBusTimetableResponse.java new file mode 100644 index 000000000..107a9fbbf --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/dto/CityBusTimetableResponse.java @@ -0,0 +1,91 @@ +package in.koreatech.koin.domain.bus.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.bus.model.mongo.CityBusTimetable; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record CityBusTimetableResponse( + @Schema(description = "업데이트 시각", example = "2024-07-18 18:00:00", requiredMode = NOT_REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime updatedAt, + + @Schema(description = "버스 정보", example = """ + { + "number": 400, + "depart_node": "병천3리", + "arrival_node": "종합터미널" + } + """, requiredMode = REQUIRED) + BusInfo busInfo, + + @Schema(description = "버스 시간표", example = """ + [ + { + "day_of_week": "평일", + "depart_info": ["06:00", "13:12", "22:30"] + }, + { + "day_of_week": "주말", + "depart_info": ["06:00", "13:12", "22:30"] + }, + { + "day_of_week": "공휴일", + "depart_info": ["06:00", "13:12", "22:30"] + }, + { + "day_of_week": "임시", + "depart_info": ["06:00", "13:12", "22:30"] + } + ] + """, requiredMode = NOT_REQUIRED) + List busTimetables +) { + + public static CityBusTimetableResponse from(CityBusTimetable timetable) { + return new CityBusTimetableResponse( + timetable.getUpdatedAt(), + BusInfo.from(timetable.getBusInfo()), + timetable.getBusTimetables().stream() + .map(BusTimetable::from) + .toList() + ); + } + + @JsonNaming(SnakeCaseStrategy.class) + public record BusInfo( + Long number, + String departNode, + String arrivalNode + ) { + public static BusInfo from(CityBusTimetable.BusInfo busInfo) { + return new BusInfo( + busInfo.getNumber(), + busInfo.getDepart(), + busInfo.getArrival() + ); + } + } + + @JsonNaming(SnakeCaseStrategy.class) + public record BusTimetable( + String dayOfWeek, + List departInfo + ) { + public static BusTimetable from(CityBusTimetable.BusTimetable timetable) { + return new BusTimetable( + timetable.getDayOfWeek(), + timetable.getDepartInfo() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/enums/CityBusDirection.java b/src/main/java/in/koreatech/koin/domain/bus/model/enums/CityBusDirection.java new file mode 100644 index 000000000..0c020d82e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/enums/CityBusDirection.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.domain.bus.model.enums; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import in.koreatech.koin.domain.bus.exception.BusTypeNotFoundException; + +public enum CityBusDirection { + 종합터미널, + 병천3리, + 황사동, + 유관순열사사적지 + ; + + @JsonCreator + public static CityBusDirection from(String direction) { + return Arrays.stream(values()) + .filter(direct -> direct.name().equalsIgnoreCase(direction)) + .findAny() + .orElseThrow(() -> BusTypeNotFoundException.withDetail("busDirection: " + direction)); + } + + public String getName() { + return this.name().toLowerCase(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/model/mongo/CityBusTimetable.java b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/CityBusTimetable.java new file mode 100644 index 000000000..7ca75c899 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/model/mongo/CityBusTimetable.java @@ -0,0 +1,75 @@ +package in.koreatech.koin.domain.bus.model.mongo; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Document(collection = "citybus_timetables") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CityBusTimetable { + + @Id + @Field("_id") + private String routeId; + + @Field("bus_info") + private BusInfo busInfo; + + @Field("bus_timetables") + private List busTimetables = new ArrayList<>(); + + @Field("updated_at") + private LocalDateTime updatedAt; + + @Builder + private CityBusTimetable(BusInfo busInfo, List busTimetables, LocalDateTime updatedAt) { + this.busInfo = busInfo; + this.busTimetables = busTimetables; + this.updatedAt = updatedAt; + } + + @Getter + public static class BusInfo { + + @Field("number") + private Long number; + + @Field("depart_node") + private String depart; + + @Field("arrival_node") + private String arrival; + + @Builder + private BusInfo(Long number, String depart, String arrival) { + this.number = number; + this.depart = depart; + this.arrival = arrival; + } + } + + @Getter + public static class BusTimetable { + @Field("day_of_week") + private String dayOfWeek; + + @Field("depart_info") + private List departInfo; + + @Builder + private BusTimetable(String dayOfWeek, List departInfo) { + this.dayOfWeek = dayOfWeek; + this.departInfo = departInfo; + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusTimetableRepository.java b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusTimetableRepository.java new file mode 100644 index 000000000..252ca36a9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/bus/repository/CityBusTimetableRepository.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.bus.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.bus.exception.BusCacheNotFoundException; +import in.koreatech.koin.domain.bus.model.mongo.CityBusTimetable; + +public interface CityBusTimetableRepository extends Repository { + + CityBusTimetable save(CityBusTimetable cityBusTimetable); + + Optional findByBusInfoNumberAndBusInfoArrival(Long number, String arrivalNode); + + default CityBusTimetable getByBusInfoNumberAndBusInfoArrival(Long number, String arrivalNode) { + return findByBusInfoNumberAndBusInfoArrival(number, arrivalNode) + .orElseThrow(() -> BusCacheNotFoundException.withDetail("number: " + number + ", direction: " + arrivalNode + " 기점 방향")); + } +} 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 ed2a3afc5..3e608c1b5 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 @@ -20,6 +20,7 @@ import in.koreatech.koin.domain.bus.dto.BusCourseResponse; import in.koreatech.koin.domain.bus.dto.BusRemainTimeResponse; import in.koreatech.koin.domain.bus.dto.BusTimetableResponse; +import in.koreatech.koin.domain.bus.dto.CityBusTimetableResponse; import in.koreatech.koin.domain.bus.dto.SingleBusTimeResponse; import in.koreatech.koin.domain.bus.exception.BusIllegalStationException; import in.koreatech.koin.domain.bus.exception.BusTypeNotFoundException; @@ -30,9 +31,12 @@ 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; +import in.koreatech.koin.domain.bus.model.enums.CityBusDirection; import in.koreatech.koin.domain.bus.model.mongo.BusCourse; +import in.koreatech.koin.domain.bus.model.mongo.CityBusTimetable; import in.koreatech.koin.domain.bus.model.mongo.Route; import in.koreatech.koin.domain.bus.repository.BusRepository; +import in.koreatech.koin.domain.bus.repository.CityBusTimetableRepository; import in.koreatech.koin.domain.bus.util.CityBusClient; import in.koreatech.koin.domain.bus.util.CityBusRouteClient; import in.koreatech.koin.domain.bus.util.TmoneyExpressBusClient; @@ -48,6 +52,7 @@ public class BusService { private final Clock clock; private final BusRepository busRepository; + private final CityBusTimetableRepository cityBusTimetableRepository; private final CityBusClient cityBusClient; private final TmoneyExpressBusClient tmoneyExpressBusClient; private final CityBusRouteClient cityBusRouteClient; @@ -217,4 +222,11 @@ public List getBusCourses() { .map(BusCourseResponse::from) .toList(); } + + public CityBusTimetableResponse getCityBusTimetable(Long busNumber, CityBusDirection direction) { + CityBusTimetable timetable = cityBusTimetableRepository + .getByBusInfoNumberAndBusInfoArrival(busNumber, direction.getName()); + + return CityBusTimetableResponse.from(timetable); + } } diff --git a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java index 570561d5e..e040e0abe 100644 --- a/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/BusApiTest.java @@ -55,6 +55,7 @@ class BusApiTest extends AcceptanceTest { @BeforeEach void setup() { busFixture.버스_시간표_등록(); + busFixture.시내버스_시간표_등록(); when(cityBusClient.getOpenApiResponse(anyString())).thenReturn(""" { "response": { @@ -373,16 +374,46 @@ void getSearchTimetable() { @Test @DisplayName("시내버스 시간표를 조회한다 - 지원하지 않음") void getCityBusTimetable() { - RestAssured + Version version = Version.builder() + .version("test_version") + .type(VersionType.CITY.getValue()) + .build(); + versionRepository.save(version); + + Long busNumber = 400L; + String direction = "종합터미널"; + + var response = RestAssured .given() .when() - .param("bus_type", "city") - .param("direction", "to") - .param("region", "천안") - .get("/bus/timetable") + .param("bus_number", busNumber) + .param("direction", direction) + .get("/bus/timetable/city") .then() - .statusCode(HttpStatus.BAD_REQUEST.value()) + .statusCode(HttpStatus.OK.value()) .extract(); + + JsonAssertions.assertThat(response.asPrettyString()) + .isEqualTo(""" + { + "bus_info": { + "arrival_node": "종합터미널", + "depart_node": "병천3리", + "number": 400 + }, + "bus_timetables": [ + { + "day_of_week": "평일", + "depart_info": ["06:00", "07:00"] + }, + { + "day_of_week": "주말", + "depart_info": ["08:00", "09:00"] + } + ], + "updated_at": "2024-07-19 19:00:00" + } + """); } @Test diff --git a/src/test/java/in/koreatech/koin/fixture/BusFixture.java b/src/test/java/in/koreatech/koin/fixture/BusFixture.java index c114335dd..dfc6c898f 100644 --- a/src/test/java/in/koreatech/koin/fixture/BusFixture.java +++ b/src/test/java/in/koreatech/koin/fixture/BusFixture.java @@ -1,13 +1,16 @@ package in.koreatech.koin.fixture; +import java.time.LocalDateTime; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import in.koreatech.koin.domain.bus.model.mongo.BusCourse; +import in.koreatech.koin.domain.bus.model.mongo.CityBusTimetable; import in.koreatech.koin.domain.bus.model.mongo.Route; import in.koreatech.koin.domain.bus.repository.BusRepository; +import in.koreatech.koin.domain.bus.repository.CityBusTimetableRepository; @Component @SuppressWarnings("NonAsciiCharacters") @@ -16,8 +19,12 @@ public final class BusFixture { @Autowired private final BusRepository busRepository; - public BusFixture(BusRepository busRepository) { + @Autowired + private final CityBusTimetableRepository cityBusTimetableRepository; + + public BusFixture(BusRepository busRepository, CityBusTimetableRepository cityBusTimetableRepository) { this.busRepository = busRepository; + this.cityBusTimetableRepository = cityBusTimetableRepository; } public void 버스_시간표_등록() { @@ -57,4 +64,29 @@ public BusFixture(BusRepository busRepository) { .build() ); } + + public void 시내버스_시간표_등록() { + cityBusTimetableRepository.save( + CityBusTimetable.builder() + .updatedAt(LocalDateTime.of(2024, 7, 19, 19, 0)) + .busInfo( + CityBusTimetable.BusInfo.builder() + .number(400L) + .depart("병천3리") + .arrival("종합터미널") + .build() + ) + .busTimetables( + List.of( + CityBusTimetable.BusTimetable.builder() + .dayOfWeek("평일") + .departInfo(List.of("06:00", "07:00")).build(), + CityBusTimetable.BusTimetable.builder() + .dayOfWeek("주말") + .departInfo(List.of("08:00", "09:00")).build() + ) + ) + .build() + ); + } }